@@ -63,4 +63,5 @@ const (
|
||||
MembershipEntityType
|
||||
SlackMessageEntityType
|
||||
TrustCenterFileEntityType
|
||||
SAMLConfigurationEntityType
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
155
pkg/coredata/migrations/20251018T194142Z.sql
Normal file
155
pkg/coredata/migrations/20251018T194142Z.sql
Normal 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);
|
||||
108
pkg/coredata/saml_assertion.go
Normal file
108
pkg/coredata/saml_assertion.go
Normal 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
|
||||
}
|
||||
453
pkg/coredata/saml_configuration.go
Normal file
453
pkg/coredata/saml_configuration.go
Normal 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
|
||||
}
|
||||
|
||||
60
pkg/coredata/saml_enforcement_policy.go
Normal file
60
pkg/coredata/saml_enforcement_policy.go
Normal 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
|
||||
}
|
||||
156
pkg/coredata/saml_relay_state.go
Normal file
156
pkg/coredata/saml_relay_state.go
Normal 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
|
||||
}
|
||||
145
pkg/coredata/saml_request.go
Normal file
145
pkg/coredata/saml_request.go
Normal 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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
22
pkg/coredata/user_auth_method.go
Normal file
22
pkg/coredata/user_auth_method.go
Normal 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"
|
||||
)
|
||||
Reference in New Issue
Block a user