Add SAML support

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2025-10-29 18:42:12 +01:00
parent 3018a3e691
commit 2766f8e423
97 changed files with 14824 additions and 2812 deletions

View File

@@ -63,4 +63,5 @@ const (
MembershipEntityType
SlackMessageEntityType
TrustCenterFileEntityType
SAMLConfigurationEntityType
)

View File

@@ -132,22 +132,33 @@ func (m *Membership) LoadByID(
membershipID gid.GID,
) error {
query := `
WITH mbr AS (
SELECT
id,
user_id,
organization_id,
role,
created_at,
updated_at
FROM
authz_memberships
WHERE
id = @membership_id
AND %s
)
SELECT
m.id,
m.user_id,
m.organization_id,
m.role,
mbr.id,
mbr.user_id,
mbr.organization_id,
mbr.role,
u.fullname as full_name,
u.email_address,
m.created_at,
m.updated_at
mbr.created_at,
mbr.updated_at
FROM
authz_memberships m
mbr
JOIN
users u ON m.user_id = u.id
WHERE
m.id = @membership_id
AND %s
users u ON mbr.user_id = u.id
`
query = fmt.Sprintf(query, scope.SQLFragment())
@@ -182,23 +193,34 @@ func (m *Membership) LoadByUserAndOrg(
orgID gid.GID,
) error {
query := `
WITH mbr AS (
SELECT
id,
user_id,
organization_id,
role,
created_at,
updated_at
FROM
authz_memberships
WHERE
user_id = @user_id
AND organization_id = @organization_id
AND %s
)
SELECT
m.id,
m.user_id,
m.organization_id,
m.role,
mbr.id,
mbr.user_id,
mbr.organization_id,
mbr.role,
u.fullname as full_name,
u.email_address,
m.created_at,
m.updated_at
mbr.created_at,
mbr.updated_at
FROM
authz_memberships m
mbr
JOIN
users u ON m.user_id = u.id
WHERE
m.user_id = @user_id
AND m.organization_id = @organization_id
AND %s
users u ON mbr.user_id = u.id
`
query = fmt.Sprintf(query, scope.SQLFragment())
@@ -294,24 +316,35 @@ func (m *Memberships) LoadByUserID(
userID gid.GID,
) error {
query := `
WITH mbr AS (
SELECT
id,
user_id,
organization_id,
role,
created_at,
updated_at
FROM
authz_memberships
WHERE
user_id = @user_id
AND %s
ORDER BY
created_at DESC
)
SELECT
m.id,
m.user_id,
m.organization_id,
m.role,
mbr.id,
mbr.user_id,
mbr.organization_id,
mbr.role,
u.fullname as full_name,
u.email_address,
m.created_at,
m.updated_at
mbr.created_at,
mbr.updated_at
FROM
authz_memberships m
mbr
JOIN
users u ON m.user_id = u.id
WHERE
m.user_id = @user_id
AND %s
ORDER BY
m.created_at DESC
users u ON mbr.user_id = u.id
`
query = fmt.Sprintf(query, scope.SQLFragment())
@@ -343,23 +376,45 @@ func (m *Memberships) LoadByOrganizationID(
cursor *page.Cursor[MembershipOrderField],
) error {
query := `
WITH mbr AS (
SELECT
id,
user_id,
organization_id,
role,
created_at,
updated_at
FROM
authz_memberships
WHERE
organization_id = @organization_id
AND %s
)
SELECT
m.id,
m.user_id,
m.organization_id,
m.role,
u.fullname as full_name,
u.email_address,
m.created_at,
m.updated_at
FROM
authz_memberships m
JOIN
users u ON m.user_id = u.id
WHERE
m.organization_id = @organization_id
AND %s
AND %s
id,
user_id,
organization_id,
role,
full_name,
email_address,
created_at,
updated_at
FROM (
SELECT
mbr.id,
mbr.user_id,
mbr.organization_id,
mbr.role,
u.fullname as full_name,
u.email_address,
mbr.created_at,
mbr.updated_at
FROM
mbr
JOIN
users u ON mbr.user_id = u.id
) AS membership_with_user
WHERE %s
`
query = fmt.Sprintf(query, scope.SQLFragment(), cursor.SQLFragment())
@@ -411,3 +466,67 @@ WHERE
}
return count, nil
}
func LoadUserIDsByOrganizationID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
organizationID gid.GID,
) ([]gid.GID, error) {
query := `
SELECT user_id
FROM authz_memberships
WHERE organization_id = @organization_id AND %s
`
query = fmt.Sprintf(query, scope.SQLFragment())
args := pgx.StrictNamedArgs{"organization_id": organizationID}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, query, args)
if err != nil {
return nil, fmt.Errorf("cannot query memberships: %w", err)
}
var userIDs []gid.GID
for rows.Next() {
var userID gid.GID
if err := rows.Scan(&userID); err != nil {
rows.Close()
return nil, fmt.Errorf("cannot scan user_id: %w", err)
}
userIDs = append(userIDs, userID)
}
rows.Close()
return userIDs, nil
}
func UpdateMembershipUserID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
oldUserID gid.GID,
newUserID gid.GID,
organizationID gid.GID,
) error {
query := `
UPDATE authz_memberships
SET user_id = @new_user_id, updated_at = @updated_at
WHERE user_id = @old_user_id AND organization_id = @organization_id AND %s
`
query = fmt.Sprintf(query, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"new_user_id": newUserID,
"old_user_id": oldUserID,
"organization_id": organizationID,
"updated_at": time.Now(),
}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, query, args)
if err != nil {
return fmt.Errorf("cannot update membership: %w", err)
}
return nil
}

View File

@@ -28,13 +28,13 @@ const (
func (p MembershipOrderField) Column() string {
switch p {
case MembershipOrderFieldFullName:
return "u.fullname"
return "full_name"
case MembershipOrderFieldEmailAddress:
return "u.email_address"
return "email_address"
case MembershipOrderFieldRole:
return "m.role"
return "role"
case MembershipOrderFieldCreatedAt:
return "m.created_at"
return "created_at"
}
return string(p)
}

View File

@@ -0,0 +1,155 @@
-- Add SAML authentication support
-- This migration adds SAML SSO functionality including:
-- - SAML configurations per organization
-- - SAML request/assertion tracking for security
-- - Domain verification
-- - User SAML subject tracking
-- Create ENUM for SAML enforcement policies
CREATE TYPE saml_enforcement_policy AS ENUM (
'OFF', -- SAML disabled, must use password
'OPTIONAL', -- SAML available but not required (default)
'REQUIRED' -- Everyone must use SAML
);
-- Create auth_saml_configurations table
CREATE TABLE auth_saml_configurations (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
organization_id TEXT NOT NULL,
email_domain TEXT NOT NULL,
-- SAML enabled flag
enabled BOOLEAN NOT NULL DEFAULT false,
-- Enforcement policy for this SAML configuration
enforcement_policy saml_enforcement_policy NOT NULL,
-- Identity Provider (IdP) configuration
idp_entity_id TEXT NOT NULL,
idp_sso_url TEXT NOT NULL,
idp_certificate TEXT NOT NULL, -- X.509 certificate (PEM format)
idp_metadata_url TEXT, -- Optional: for auto-refresh
-- Attribute mapping configuration (using WS-Federation Claims)
attribute_email TEXT NOT NULL DEFAULT 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress',
attribute_firstname TEXT NOT NULL DEFAULT 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname',
attribute_lastname TEXT NOT NULL DEFAULT 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname',
attribute_role TEXT NOT NULL DEFAULT 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/role',
-- Default role if mapping fails or attribute missing
default_role TEXT NOT NULL DEFAULT 'MEMBER',
-- Auto-signup settings
auto_signup_enabled BOOLEAN NOT NULL DEFAULT false,
-- Domain verification fields
domain_verified BOOLEAN NOT NULL DEFAULT false,
domain_verification_token TEXT,
domain_verified_at TIMESTAMP,
-- Timestamps
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
CONSTRAINT fk_auth_saml_configurations_organization FOREIGN KEY (organization_id)
REFERENCES organizations(id) ON DELETE CASCADE
);
-- Index for fast organization lookup
CREATE INDEX idx_auth_saml_configurations_organization_id
ON auth_saml_configurations(organization_id);
-- Index for tenant scoping
CREATE INDEX idx_auth_saml_configurations_tenant_id
ON auth_saml_configurations(tenant_id);
-- Unique constraint scoped to organization
-- This allows the same domain in different organizations
-- while preventing duplicates within the same organization
CREATE UNIQUE INDEX idx_saml_config_domain_org_unique
ON auth_saml_configurations(organization_id, email_domain)
WHERE enabled = true AND domain_verified = true;
-- Index for fast domain lookup (for email-based SAML discovery)
CREATE INDEX idx_saml_config_email_domain
ON auth_saml_configurations(email_domain)
WHERE enabled = true AND domain_verified = true;
-- Add SAML subject to users table for tracking SAML NameID
ALTER TABLE users ADD COLUMN saml_subject TEXT;
-- Unique constraint: one SAML subject globally
CREATE UNIQUE INDEX idx_users_saml_subject
ON users(saml_subject)
WHERE saml_subject IS NOT NULL;
-- Make hashed_password nullable for SAML users
-- SAML users authenticate via IdP and don't have passwords
ALTER TABLE users ALTER COLUMN hashed_password DROP NOT NULL;
-- Create auth_saml_assertions table for replay attack prevention
CREATE TABLE auth_saml_assertions (
id TEXT PRIMARY KEY, -- SAML Assertion ID
tenant_id TEXT NOT NULL,
organization_id TEXT NOT NULL,
used_at TIMESTAMP NOT NULL,
expires_at TIMESTAMP NOT NULL
);
-- Index for cleanup of expired assertions
CREATE INDEX idx_auth_saml_assertions_expires_at
ON auth_saml_assertions(expires_at);
-- Index for organization lookup
CREATE INDEX idx_auth_saml_assertions_organization_id
ON auth_saml_assertions(organization_id);
-- Index for tenant scoping
CREATE INDEX idx_auth_saml_assertions_tenant_id
ON auth_saml_assertions(tenant_id);
-- Create auth_saml_requests table for proper InResponseTo validation
-- This prevents replay attacks and validates the SAML authentication flow
CREATE TABLE auth_saml_requests (
id TEXT PRIMARY KEY, -- SAML Request ID generated by SP
organization_id TEXT NOT NULL,
tenant_id TEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
expires_at TIMESTAMP NOT NULL,
CONSTRAINT fk_auth_saml_requests_organization FOREIGN KEY (organization_id)
REFERENCES organizations(id) ON DELETE CASCADE
);
-- Index for fast request ID lookup during SAML callback
CREATE INDEX idx_auth_saml_requests_id_org ON auth_saml_requests(id, organization_id);
-- Index for cleanup of expired requests
CREATE INDEX idx_auth_saml_requests_expires_at ON auth_saml_requests(expires_at);
-- Create auth_saml_relay_states table for secure RelayState management
-- This prevents organization hijacking attacks
CREATE TABLE auth_saml_relay_states (
token TEXT PRIMARY KEY, -- Cryptographically secure random token
tenant_id TEXT NOT NULL,
organization_id TEXT NOT NULL,
request_id TEXT NOT NULL, -- Links to auth_saml_requests.id
saml_config_id TEXT NOT NULL, -- Links to auth_saml_configurations.id
created_at TIMESTAMP NOT NULL,
expires_at TIMESTAMP NOT NULL,
CONSTRAINT fk_auth_saml_relay_states_organization FOREIGN KEY (organization_id)
REFERENCES organizations(id) ON DELETE CASCADE,
CONSTRAINT fk_auth_saml_relay_states_saml_config FOREIGN KEY (saml_config_id)
REFERENCES auth_saml_configurations(id) ON DELETE CASCADE
);
-- Index for fast token lookup during callback
CREATE INDEX idx_auth_saml_relay_states_token ON auth_saml_relay_states(token);
-- Index for cleanup of expired relay states
CREATE INDEX idx_auth_saml_relay_states_expires_at ON auth_saml_relay_states(expires_at);
-- Index for tenant scoping
CREATE INDEX idx_auth_saml_relay_states_tenant_id ON auth_saml_relay_states(tenant_id);

View File

@@ -0,0 +1,108 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package coredata
import (
"context"
"fmt"
"time"
"github.com/getprobo/probo/pkg/gid"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
)
type SAMLAssertion struct {
ID string `db:"id"`
OrganizationID gid.GID `db:"organization_id"`
UsedAt time.Time `db:"used_at"`
ExpiresAt time.Time `db:"expires_at"`
}
type ErrAssertionAlreadyUsed struct {
AssertionID string
}
func (e ErrAssertionAlreadyUsed) Error() string {
return fmt.Sprintf("assertion ID %q has already been used (replay attack)", e.AssertionID)
}
func (s *SAMLAssertion) CheckExists(
ctx context.Context,
conn pg.Conn,
assertionID string,
) (bool, error) {
query := `
SELECT id
FROM auth_saml_assertions
WHERE id = @id
LIMIT 1
`
rows, err := conn.Query(ctx, query, pgx.NamedArgs{"id": assertionID})
if err != nil {
return false, fmt.Errorf("cannot query saml_assertions: %w", err)
}
_, err = pgx.CollectOneRow(rows, pgx.RowTo[string])
if err == nil {
return true, nil
}
if err == pgx.ErrNoRows {
return false, nil
}
return false, fmt.Errorf("cannot collect saml_assertion: %w", err)
}
func (s *SAMLAssertion) Insert(
ctx context.Context,
conn pg.Conn,
scope Scoper,
) error {
query := `
INSERT INTO auth_saml_assertions (id, tenant_id, organization_id, used_at, expires_at)
VALUES (@id, @tenant_id, @organization_id, @used_at, @expires_at)
`
args := pgx.NamedArgs{
"id": s.ID,
"tenant_id": scope.GetTenantID(),
"organization_id": s.OrganizationID,
"used_at": s.UsedAt,
"expires_at": s.ExpiresAt,
}
_, err := conn.Exec(ctx, query, args)
if err != nil {
return fmt.Errorf("cannot insert saml_assertion: %w", err)
}
return nil
}
func DeleteExpiredSAMLAssertions(ctx context.Context, conn pg.Conn, now time.Time) (int64, error) {
query := `
DELETE FROM auth_saml_assertions
WHERE expires_at < @now
`
result, err := conn.Exec(ctx, query, pgx.NamedArgs{"now": now})
if err != nil {
return 0, fmt.Errorf("cannot delete expired saml_assertions: %w", err)
}
return result.RowsAffected(), nil
}

View File

@@ -0,0 +1,453 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package coredata
import (
"context"
"fmt"
"maps"
"time"
"github.com/getprobo/probo/pkg/gid"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
)
type SAMLConfiguration struct {
ID gid.GID `db:"id"`
OrganizationID gid.GID `db:"organization_id"`
EmailDomain string `db:"email_domain"`
Enabled bool `db:"enabled"`
EnforcementPolicy SAMLEnforcementPolicy `db:"enforcement_policy"`
IdPEntityID string `db:"idp_entity_id"`
IdPSsoURL string `db:"idp_sso_url"`
IdPCertificate string `db:"idp_certificate"`
IdPMetadataURL *string `db:"idp_metadata_url"`
AttributeEmail string `db:"attribute_email"`
AttributeFirstname string `db:"attribute_firstname"`
AttributeLastname string `db:"attribute_lastname"`
AttributeRole string `db:"attribute_role"`
DefaultRole string `db:"default_role"`
AutoSignupEnabled bool `db:"auto_signup_enabled"`
DomainVerified bool `db:"domain_verified"`
DomainVerificationToken *string `db:"domain_verification_token"`
DomainVerifiedAt *time.Time `db:"domain_verified_at"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
func (s *SAMLConfiguration) LoadByOrganizationIDAndEmailDomain(
ctx context.Context,
conn pg.Conn,
scope Scoper,
organizationID gid.GID,
emailDomain string,
) error {
q := `
SELECT
id,
organization_id,
email_domain,
enabled,
enforcement_policy,
idp_entity_id,
idp_sso_url,
idp_certificate,
idp_metadata_url,
attribute_email,
attribute_firstname,
attribute_lastname,
attribute_role,
default_role,
auto_signup_enabled,
domain_verified,
domain_verification_token,
domain_verified_at,
created_at,
updated_at
FROM
auth_saml_configurations
WHERE
%s
AND organization_id = @organization_id
AND email_domain = @email_domain
LIMIT 1;
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"organization_id": organizationID,
"email_domain": emailDomain,
}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query auth_saml_configurations: %w", err)
}
config, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[SAMLConfiguration])
if err != nil {
return fmt.Errorf("cannot collect saml_configuration: %w", err)
}
*s = config
return nil
}
func (s *SAMLConfiguration) LoadByID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
configID gid.GID,
) error {
q := `
SELECT
id,
organization_id,
email_domain,
enabled,
enforcement_policy,
idp_entity_id,
idp_sso_url,
idp_certificate,
idp_metadata_url,
attribute_email,
attribute_firstname,
attribute_lastname,
attribute_role,
default_role,
auto_signup_enabled,
domain_verified,
domain_verification_token,
domain_verified_at,
created_at,
updated_at
FROM
auth_saml_configurations
WHERE
%s
AND id = @id
LIMIT 1;
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"id": configID}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query auth_saml_configurations: %w", err)
}
config, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[SAMLConfiguration])
if err != nil {
return fmt.Errorf("cannot collect saml_configuration: %w", err)
}
*s = config
return nil
}
func (s *SAMLConfiguration) Insert(
ctx context.Context,
conn pg.Conn,
scope Scoper,
) error {
q := `
INSERT INTO auth_saml_configurations (
id,
tenant_id,
organization_id,
email_domain,
enabled,
enforcement_policy,
idp_entity_id,
idp_sso_url,
idp_certificate,
idp_metadata_url,
attribute_email,
attribute_firstname,
attribute_lastname,
attribute_role,
default_role,
auto_signup_enabled,
domain_verified,
domain_verification_token,
domain_verified_at,
created_at,
updated_at
) VALUES (
@id,
@tenant_id,
@organization_id,
@email_domain,
@enabled,
@enforcement_policy,
@idp_entity_id,
@idp_sso_url,
@idp_certificate,
@idp_metadata_url,
@attribute_email,
@attribute_firstname,
@attribute_lastname,
@attribute_role,
@default_role,
@auto_signup_enabled,
@domain_verified,
@domain_verification_token,
@domain_verified_at,
@created_at,
@updated_at
)
`
args := pgx.StrictNamedArgs{
"id": s.ID,
"tenant_id": scope.GetTenantID(),
"organization_id": s.OrganizationID,
"email_domain": s.EmailDomain,
"enabled": s.Enabled,
"enforcement_policy": s.EnforcementPolicy,
"idp_entity_id": s.IdPEntityID,
"idp_sso_url": s.IdPSsoURL,
"idp_certificate": s.IdPCertificate,
"idp_metadata_url": s.IdPMetadataURL,
"attribute_email": s.AttributeEmail,
"attribute_firstname": s.AttributeFirstname,
"attribute_lastname": s.AttributeLastname,
"attribute_role": s.AttributeRole,
"default_role": s.DefaultRole,
"auto_signup_enabled": s.AutoSignupEnabled,
"domain_verified": s.DomainVerified,
"domain_verification_token": s.DomainVerificationToken,
"domain_verified_at": s.DomainVerifiedAt,
"created_at": s.CreatedAt,
"updated_at": s.UpdatedAt,
}
_, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot insert saml_configuration: %w", err)
}
return nil
}
func (s *SAMLConfiguration) Update(
ctx context.Context,
conn pg.Conn,
scope Scoper,
) error {
q := `
UPDATE auth_saml_configurations
SET
enabled = @enabled,
enforcement_policy = @enforcement_policy,
idp_entity_id = @idp_entity_id,
idp_sso_url = @idp_sso_url,
idp_certificate = @idp_certificate,
idp_metadata_url = @idp_metadata_url,
attribute_email = @attribute_email,
attribute_firstname = @attribute_firstname,
attribute_lastname = @attribute_lastname,
attribute_role = @attribute_role,
default_role = @default_role,
auto_signup_enabled = @auto_signup_enabled,
domain_verified = @domain_verified,
domain_verification_token = @domain_verification_token,
domain_verified_at = @domain_verified_at,
updated_at = @updated_at
WHERE
%s
AND id = @id
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{
"id": s.ID,
"enabled": s.Enabled,
"enforcement_policy": s.EnforcementPolicy,
"idp_entity_id": s.IdPEntityID,
"idp_sso_url": s.IdPSsoURL,
"idp_certificate": s.IdPCertificate,
"idp_metadata_url": s.IdPMetadataURL,
"attribute_email": s.AttributeEmail,
"attribute_firstname": s.AttributeFirstname,
"attribute_lastname": s.AttributeLastname,
"attribute_role": s.AttributeRole,
"default_role": s.DefaultRole,
"auto_signup_enabled": s.AutoSignupEnabled,
"domain_verified": s.DomainVerified,
"domain_verification_token": s.DomainVerificationToken,
"domain_verified_at": s.DomainVerifiedAt,
"updated_at": s.UpdatedAt,
}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot update saml_configuration: %w", err)
}
return nil
}
func (s *SAMLConfiguration) Delete(
ctx context.Context,
conn pg.Conn,
scope Scoper,
) error {
q := `
DELETE FROM auth_saml_configurations
WHERE
%s
AND id = @id
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"id": s.ID}
maps.Copy(args, scope.SQLArguments())
_, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot delete saml_configuration: %w", err)
}
return nil
}
func LoadSAMLConfigurationsByOrganizationID(
ctx context.Context,
conn pg.Conn,
scope Scoper,
organizationID gid.GID,
) ([]*SAMLConfiguration, error) {
q := `
SELECT
id,
organization_id,
email_domain,
enabled,
enforcement_policy,
idp_entity_id,
idp_sso_url,
idp_certificate,
idp_metadata_url,
attribute_email,
attribute_firstname,
attribute_lastname,
attribute_role,
default_role,
auto_signup_enabled,
domain_verified,
domain_verification_token,
domain_verified_at,
created_at,
updated_at
FROM
auth_saml_configurations
WHERE
%s
AND organization_id = @organization_id
ORDER BY email_domain ASC;
`
q = fmt.Sprintf(q, scope.SQLFragment())
args := pgx.StrictNamedArgs{"organization_id": organizationID}
maps.Copy(args, scope.SQLArguments())
rows, err := conn.Query(ctx, q, args)
if err != nil {
return nil, fmt.Errorf("cannot query auth_saml_configurations: %w", err)
}
configs, err := pgx.CollectRows(rows, pgx.RowToStructByName[SAMLConfiguration])
if err != nil {
return nil, fmt.Errorf("cannot collect saml_configurations: %w", err)
}
result := make([]*SAMLConfiguration, len(configs))
for i := range configs {
result[i] = &configs[i]
}
return result, nil
}
// LoadAllEnabledSAMLConfigurationsByEmailDomain loads all enabled SAML configurations for a given email domain
// This is used for SSO login detection when multiple organizations may have SAML configured for the same domain
func LoadAllEnabledSAMLConfigurationsByEmailDomain(
ctx context.Context,
conn pg.Conn,
emailDomain string,
) ([]*SAMLConfiguration, error) {
q := `
SELECT
id,
organization_id,
email_domain,
enabled,
enforcement_policy,
idp_entity_id,
idp_sso_url,
idp_certificate,
idp_metadata_url,
attribute_email,
attribute_firstname,
attribute_lastname,
attribute_role,
default_role,
auto_signup_enabled,
domain_verified,
domain_verification_token,
domain_verified_at,
created_at,
updated_at
FROM
auth_saml_configurations
WHERE
email_domain = $1
AND enabled = true
AND domain_verified = true
ORDER BY created_at ASC;
`
rows, err := conn.Query(ctx, q, emailDomain)
if err != nil {
return nil, fmt.Errorf("cannot query auth_saml_configurations: %w", err)
}
configs, err := pgx.CollectRows(rows, pgx.RowToStructByName[SAMLConfiguration])
if err != nil {
return nil, fmt.Errorf("cannot collect saml_configurations: %w", err)
}
result := make([]*SAMLConfiguration, len(configs))
for i := range configs {
result[i] = &configs[i]
}
return result, nil
}

View File

@@ -0,0 +1,60 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package coredata
import (
"database/sql/driver"
"fmt"
)
type SAMLEnforcementPolicy string
const (
SAMLEnforcementPolicyOff SAMLEnforcementPolicy = "OFF"
SAMLEnforcementPolicyOptional SAMLEnforcementPolicy = "OPTIONAL"
SAMLEnforcementPolicyRequired SAMLEnforcementPolicy = "REQUIRED"
)
func (sep SAMLEnforcementPolicy) String() string {
return string(sep)
}
func (sep *SAMLEnforcementPolicy) Scan(value any) error {
var s string
switch v := value.(type) {
case string:
s = v
case []byte:
s = string(v)
default:
return fmt.Errorf("unsupported type for SAMLEnforcementPolicy: %T", value)
}
switch s {
case "OFF":
*sep = SAMLEnforcementPolicyOff
case "OPTIONAL":
*sep = SAMLEnforcementPolicyOptional
case "REQUIRED":
*sep = SAMLEnforcementPolicyRequired
default:
return fmt.Errorf("invalid SAMLEnforcementPolicy value: %q", s)
}
return nil
}
func (sep SAMLEnforcementPolicy) Value() (driver.Value, error) {
return sep.String(), nil
}

View File

@@ -0,0 +1,156 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package coredata
import (
"context"
"crypto/rand"
"encoding/base64"
"fmt"
"time"
"github.com/getprobo/probo/pkg/gid"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
)
type SAMLRelayState struct {
Token string `db:"token"`
OrganizationID gid.GID `db:"organization_id"`
SAMLConfigID gid.GID `db:"saml_config_id"`
RequestID string `db:"request_id"`
CreatedAt time.Time `db:"created_at"`
ExpiresAt time.Time `db:"expires_at"`
}
type ErrRelayStateNotFound struct {
Token string
}
func (e ErrRelayStateNotFound) Error() string {
return "relay state token not found or invalid"
}
type ErrRelayStateExpired struct {
Token string
ExpiresAt time.Time
}
func (e ErrRelayStateExpired) Error() string {
return fmt.Sprintf("relay state token expired at %v", e.ExpiresAt)
}
func GenerateSecureToken() (string, error) {
b := make([]byte, 32)
_, err := rand.Read(b)
if err != nil {
return "", fmt.Errorf("cannot generate random token: %w", err)
}
token := base64.URLEncoding.EncodeToString(b)
return token, nil
}
func (s *SAMLRelayState) Insert(
ctx context.Context,
conn pg.Conn,
scope Scoper,
) error {
query := `
INSERT INTO auth_saml_relay_states (token, tenant_id, organization_id, saml_config_id, request_id, created_at, expires_at)
VALUES (@token, @tenant_id, @organization_id, @saml_config_id, @request_id, @created_at, @expires_at)
`
args := pgx.NamedArgs{
"token": s.Token,
"tenant_id": scope.GetTenantID(),
"organization_id": s.OrganizationID,
"saml_config_id": s.SAMLConfigID,
"request_id": s.RequestID,
"created_at": s.CreatedAt,
"expires_at": s.ExpiresAt,
}
_, err := conn.Exec(ctx, query, args)
if err != nil {
return fmt.Errorf("cannot insert saml_relay_state: %w", err)
}
return nil
}
func (s *SAMLRelayState) Load(
ctx context.Context,
conn pg.Conn,
token string,
) error {
query := `
SELECT token, organization_id, saml_config_id, request_id, created_at, expires_at
FROM auth_saml_relay_states
WHERE token = @token
LIMIT 1
`
rows, err := conn.Query(ctx, query, pgx.NamedArgs{"token": token})
if err != nil {
return fmt.Errorf("cannot query saml_relay_states: %w", err)
}
state, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[SAMLRelayState])
if err == pgx.ErrNoRows {
return ErrRelayStateNotFound{Token: token}
}
if err != nil {
return fmt.Errorf("cannot collect saml_relay_state: %w", err)
}
*s = state
return nil
}
func (s *SAMLRelayState) IsExpired(now time.Time) bool {
return now.After(s.ExpiresAt) || now.Equal(s.ExpiresAt)
}
func (s *SAMLRelayState) Delete(
ctx context.Context,
conn pg.Conn,
) error {
query := `
DELETE FROM auth_saml_relay_states
WHERE token = @token
`
_, err := conn.Exec(ctx, query, pgx.NamedArgs{"token": s.Token})
if err != nil {
return fmt.Errorf("cannot delete saml_relay_state: %w", err)
}
return nil
}
func DeleteExpiredSAMLRelayStates(ctx context.Context, conn pg.Conn, now time.Time) (int64, error) {
query := `
DELETE FROM auth_saml_relay_states
WHERE expires_at < @now
`
result, err := conn.Exec(ctx, query, pgx.NamedArgs{"now": now})
if err != nil {
return 0, fmt.Errorf("cannot delete expired saml_relay_states: %w", err)
}
return result.RowsAffected(), nil
}

View File

@@ -0,0 +1,145 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package coredata
import (
"context"
"fmt"
"time"
"github.com/getprobo/probo/pkg/gid"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/pg"
)
type SAMLRequest struct {
ID string `db:"id"`
OrganizationID gid.GID `db:"organization_id"`
CreatedAt time.Time `db:"created_at"`
ExpiresAt time.Time `db:"expires_at"`
}
type ErrSAMLRequestNotFound struct {
RequestID string
}
func (e ErrSAMLRequestNotFound) Error() string {
return fmt.Sprintf("SAML request ID %q not found", e.RequestID)
}
type ErrSAMLRequestExpired struct {
RequestID string
ExpiresAt time.Time
}
func (e ErrSAMLRequestExpired) Error() string {
return fmt.Sprintf("SAML request ID %q expired at %v", e.RequestID, e.ExpiresAt)
}
func (s *SAMLRequest) Insert(
ctx context.Context,
conn pg.Conn,
scope Scoper,
) error {
query := `
INSERT INTO auth_saml_requests (id, organization_id, tenant_id, created_at, expires_at)
VALUES (@id, @organization_id, @tenant_id, @created_at, @expires_at)
`
args := pgx.NamedArgs{
"id": s.ID,
"organization_id": s.OrganizationID,
"tenant_id": scope.GetTenantID(),
"created_at": s.CreatedAt,
"expires_at": s.ExpiresAt,
}
_, err := conn.Exec(ctx, query, args)
if err != nil {
return fmt.Errorf("cannot insert saml_request: %w", err)
}
return nil
}
func (s *SAMLRequest) Load(
ctx context.Context,
conn pg.Conn,
requestID string,
organizationID gid.GID,
) error {
query := `
SELECT id, organization_id, created_at, expires_at
FROM auth_saml_requests
WHERE id = @id AND organization_id = @organization_id
LIMIT 1
`
args := pgx.NamedArgs{
"id": requestID,
"organization_id": organizationID,
}
rows, err := conn.Query(ctx, query, args)
if err != nil {
return fmt.Errorf("cannot query saml_requests: %w", err)
}
req, err := pgx.CollectOneRow(rows, pgx.RowToStructByName[SAMLRequest])
if err == pgx.ErrNoRows {
return ErrSAMLRequestNotFound{RequestID: requestID}
}
if err != nil {
return fmt.Errorf("cannot collect saml_request: %w", err)
}
*s = req
return nil
}
func (s *SAMLRequest) IsExpired(now time.Time) bool {
return now.After(s.ExpiresAt) || now.Equal(s.ExpiresAt)
}
func (s *SAMLRequest) Delete(
ctx context.Context,
conn pg.Conn,
) error {
query := `
DELETE FROM auth_saml_requests
WHERE id = @id
`
_, err := conn.Exec(ctx, query, pgx.NamedArgs{"id": s.ID})
if err != nil {
return fmt.Errorf("cannot delete saml_request: %w", err)
}
return nil
}
func DeleteExpiredSAMLRequests(ctx context.Context, conn pg.Conn, now time.Time) (int64, error) {
query := `
DELETE FROM auth_saml_requests
WHERE expires_at < @now
`
result, err := conn.Exec(ctx, query, pgx.NamedArgs{"now": now})
if err != nil {
return 0, fmt.Errorf("cannot delete expired saml_requests: %w", err)
}
return result.RowsAffected(), nil
}

View File

@@ -35,7 +35,30 @@ type (
UpdatedAt time.Time `db:"updated_at"`
}
SessionData struct{}
// SessionData stores authentication context for a user session
// Stored as JSONB in database
SessionData struct {
// PasswordAuthenticated indicates if user authenticated with email/password
// Required for accessing organizations without SAML
PasswordAuthenticated bool `json:"password_authenticated"`
// SAMLAuthenticatedOrgs tracks which organizations user has SAML-authenticated for
// Key: organization ID as string, Value: SAML authentication info
// Required for accessing organizations with SAML enforcement
SAMLAuthenticatedOrgs map[string]SAMLAuthInfo `json:"saml_authenticated_orgs,omitempty"`
}
// SAMLAuthInfo stores SAML authentication details for an organization
SAMLAuthInfo struct {
// AuthenticatedAt is when the user SAML-
AuthenticatedAt time.Time `json:"authenticated_at"`
// SAMLConfigID is the SAML configuration used for authentication
SAMLConfigID gid.GID `json:"saml_config_id"`
// SAMLSubject is the NameID from the SAML assertion (email address)
SAMLSubject string `json:"saml_subject"`
}
)
func (s Session) CursorKey(orderBy SessionOrderField) page.CursorKey {
@@ -47,7 +70,6 @@ func (s Session) CursorKey(orderBy SessionOrderField) page.CursorKey {
panic(fmt.Sprintf("unsupported order by: %s", orderBy))
}
// Tenant id scope is not applied because we want to access sessions across all tenants for authentication purposes.
func (s *Session) LoadByID(
ctx context.Context,
conn pg.Conn,

View File

@@ -36,6 +36,7 @@ type (
HashedPassword []byte `db:"hashed_password"`
FullName string `db:"fullname"`
EmailAddressVerified bool `db:"email_address_verified"`
SAMLSubject *string `db:"saml_subject"`
CreatedAt time.Time `db:"created_at"`
UpdatedAt time.Time `db:"updated_at"`
}
@@ -158,6 +159,7 @@ SELECT
hashed_password,
email_address_verified,
fullname,
saml_subject,
created_at,
updated_at
FROM
@@ -201,6 +203,7 @@ SELECT
hashed_password,
email_address_verified,
fullname,
saml_subject,
created_at,
updated_at
FROM
@@ -234,16 +237,18 @@ LIMIT 1;
func (u *User) Insert(
ctx context.Context,
conn pg.Conn,
scope Scoper,
) error {
q := `
INSERT INTO
users (id, email_address, hashed_password, email_address_verified, fullname, created_at, updated_at)
users (id, email_address, hashed_password, email_address_verified, fullname, saml_subject, created_at, updated_at)
VALUES (
@user_id,
@email_address,
@hashed_password,
@email_address_verified,
@fullname,
@saml_subject,
@created_at,
@updated_at
)
@@ -254,6 +259,7 @@ VALUES (
"email_address": u.EmailAddress,
"hashed_password": u.HashedPassword,
"fullname": u.FullName,
"saml_subject": u.SAMLSubject,
"created_at": u.CreatedAt,
"updated_at": u.UpdatedAt,
"email_address_verified": u.EmailAddressVerified,
@@ -341,3 +347,107 @@ WHERE
return nil
}
func (u *User) Update(ctx context.Context, conn pg.Conn) error {
q := `
UPDATE
users
SET
email_address = @email_address,
email_address_verified = @email_address_verified,
saml_subject = @saml_subject,
updated_at = @updated_at
WHERE
id = @user_id
`
args := pgx.StrictNamedArgs{
"user_id": u.ID,
"email_address": u.EmailAddress,
"email_address_verified": u.EmailAddressVerified,
"saml_subject": u.SAMLSubject,
"updated_at": u.UpdatedAt,
}
_, err := conn.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot update user: %w", err)
}
return nil
}
// LoadBySAMLSubject loads a user by their SAML subject (NameID)
func (u *User) LoadBySAMLSubject(
ctx context.Context,
conn pg.Conn,
samlSubject string,
) error {
q := `
SELECT
id,
email_address,
hashed_password,
email_address_verified,
fullname,
saml_subject,
created_at,
updated_at
FROM
users
WHERE
saml_subject = @saml_subject
LIMIT 1;
`
args := pgx.StrictNamedArgs{"saml_subject": samlSubject}
rows, err := conn.Query(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot query user by SAML subject: %w", err)
}
user, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[User])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &ErrUserNotFound{Identifier: samlSubject}
}
return fmt.Errorf("cannot collect user: %w", err)
}
*u = user
return nil
}
// LoadByEmailAndTenant, LoadByEmailGlobal, and IsTenantUser methods removed
// All users are now global (no tenant_id distinction)
// Use LoadByEmail() for all email-based lookups
func (u *User) CountMemberships(
ctx context.Context,
conn pg.Conn,
) (int, error) {
q := `
SELECT
COUNT(*)
FROM
authz_memberships
WHERE
user_id = @user_id
`
args := pgx.StrictNamedArgs{"user_id": u.ID}
var count int
err := conn.QueryRow(ctx, q, args).Scan(&count)
if err != nil {
return 0, fmt.Errorf("cannot count user memberships: %w", err)
}
return count, nil
}
// ConvertToTenantUser method removed
// All users are now global (no tenant conversion needed)

View File

@@ -0,0 +1,22 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package coredata
type UserAuthMethod string
const (
UserAuthMethodPassword UserAuthMethod = "PASSWORD"
UserAuthMethodSAML UserAuthMethod = "SAML"
)