Files
breakpilot-compliance/ai-compliance-sdk/internal/sso/store.go
Benjamin Boenisch 504dd3591b feat: Add Academy, Whistleblower, Incidents, Vendor, DSB, SSO, Reporting, Multi-Tenant and Industry backends
Go handlers, models, stores and migrations for all SDK modules.
Updates developer portal navigation and BYOEH page.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 21:11:27 +01:00

478 lines
14 KiB
Go

package sso
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Store handles SSO configuration and user data persistence.
type Store struct {
pool *pgxpool.Pool
}
// NewStore creates a new SSO store.
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// ============================================================================
// SSO Configuration CRUD Operations
// ============================================================================
// CreateConfig creates a new SSO configuration for a tenant.
func (s *Store) CreateConfig(ctx context.Context, tenantID uuid.UUID, req *CreateSSOConfigRequest) (*SSOConfig, error) {
now := time.Now().UTC()
cfg := &SSOConfig{
ID: uuid.New(),
TenantID: tenantID,
ProviderType: req.ProviderType,
Name: req.Name,
Enabled: req.Enabled,
OIDCIssuerURL: req.OIDCIssuerURL,
OIDCClientID: req.OIDCClientID,
OIDCClientSecret: req.OIDCClientSecret,
OIDCRedirectURI: req.OIDCRedirectURI,
OIDCScopes: req.OIDCScopes,
RoleMapping: req.RoleMapping,
DefaultRoleID: req.DefaultRoleID,
AutoProvision: req.AutoProvision,
CreatedAt: now,
UpdatedAt: now,
}
// Apply defaults
if len(cfg.OIDCScopes) == 0 {
cfg.OIDCScopes = []string{"openid", "profile", "email"}
}
if cfg.RoleMapping == nil {
cfg.RoleMapping = map[string]string{}
}
roleMappingJSON, err := json.Marshal(cfg.RoleMapping)
if err != nil {
return nil, fmt.Errorf("failed to marshal role_mapping: %w", err)
}
_, err = s.pool.Exec(ctx, `
INSERT INTO sso_configurations (
id, tenant_id, provider_type, name, enabled,
oidc_issuer_url, oidc_client_id, oidc_client_secret, oidc_redirect_uri, oidc_scopes,
saml_entity_id, saml_sso_url, saml_certificate, saml_acs_url,
role_mapping, default_role_id, auto_provision,
created_at, updated_at
) VALUES (
$1, $2, $3, $4, $5,
$6, $7, $8, $9, $10,
$11, $12, $13, $14,
$15, $16, $17,
$18, $19
)
`,
cfg.ID, cfg.TenantID, string(cfg.ProviderType), cfg.Name, cfg.Enabled,
cfg.OIDCIssuerURL, cfg.OIDCClientID, cfg.OIDCClientSecret, cfg.OIDCRedirectURI, cfg.OIDCScopes,
cfg.SAMLEntityID, cfg.SAMLSSOURL, cfg.SAMLCertificate, cfg.SAMLACS_URL,
roleMappingJSON, cfg.DefaultRoleID, cfg.AutoProvision,
cfg.CreatedAt, cfg.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to insert sso configuration: %w", err)
}
return cfg, nil
}
// GetConfig retrieves an SSO configuration by ID and tenant.
func (s *Store) GetConfig(ctx context.Context, tenantID, configID uuid.UUID) (*SSOConfig, error) {
var cfg SSOConfig
var providerType string
var roleMappingJSON []byte
err := s.pool.QueryRow(ctx, `
SELECT
id, tenant_id, provider_type, name, enabled,
oidc_issuer_url, oidc_client_id, oidc_client_secret, oidc_redirect_uri, oidc_scopes,
saml_entity_id, saml_sso_url, saml_certificate, saml_acs_url,
role_mapping, default_role_id, auto_provision,
created_at, updated_at
FROM sso_configurations
WHERE id = $1 AND tenant_id = $2
`, configID, tenantID).Scan(
&cfg.ID, &cfg.TenantID, &providerType, &cfg.Name, &cfg.Enabled,
&cfg.OIDCIssuerURL, &cfg.OIDCClientID, &cfg.OIDCClientSecret, &cfg.OIDCRedirectURI, &cfg.OIDCScopes,
&cfg.SAMLEntityID, &cfg.SAMLSSOURL, &cfg.SAMLCertificate, &cfg.SAMLACS_URL,
&roleMappingJSON, &cfg.DefaultRoleID, &cfg.AutoProvision,
&cfg.CreatedAt, &cfg.UpdatedAt,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get sso configuration: %w", err)
}
cfg.ProviderType = ProviderType(providerType)
cfg.RoleMapping = unmarshalRoleMapping(roleMappingJSON)
return &cfg, nil
}
// GetConfigByName retrieves an SSO configuration by name and tenant.
func (s *Store) GetConfigByName(ctx context.Context, tenantID uuid.UUID, name string) (*SSOConfig, error) {
var cfg SSOConfig
var providerType string
var roleMappingJSON []byte
err := s.pool.QueryRow(ctx, `
SELECT
id, tenant_id, provider_type, name, enabled,
oidc_issuer_url, oidc_client_id, oidc_client_secret, oidc_redirect_uri, oidc_scopes,
saml_entity_id, saml_sso_url, saml_certificate, saml_acs_url,
role_mapping, default_role_id, auto_provision,
created_at, updated_at
FROM sso_configurations
WHERE tenant_id = $1 AND name = $2
`, tenantID, name).Scan(
&cfg.ID, &cfg.TenantID, &providerType, &cfg.Name, &cfg.Enabled,
&cfg.OIDCIssuerURL, &cfg.OIDCClientID, &cfg.OIDCClientSecret, &cfg.OIDCRedirectURI, &cfg.OIDCScopes,
&cfg.SAMLEntityID, &cfg.SAMLSSOURL, &cfg.SAMLCertificate, &cfg.SAMLACS_URL,
&roleMappingJSON, &cfg.DefaultRoleID, &cfg.AutoProvision,
&cfg.CreatedAt, &cfg.UpdatedAt,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get sso configuration by name: %w", err)
}
cfg.ProviderType = ProviderType(providerType)
cfg.RoleMapping = unmarshalRoleMapping(roleMappingJSON)
return &cfg, nil
}
// ListConfigs lists all SSO configurations for a tenant.
func (s *Store) ListConfigs(ctx context.Context, tenantID uuid.UUID) ([]SSOConfig, error) {
rows, err := s.pool.Query(ctx, `
SELECT
id, tenant_id, provider_type, name, enabled,
oidc_issuer_url, oidc_client_id, oidc_client_secret, oidc_redirect_uri, oidc_scopes,
saml_entity_id, saml_sso_url, saml_certificate, saml_acs_url,
role_mapping, default_role_id, auto_provision,
created_at, updated_at
FROM sso_configurations
WHERE tenant_id = $1
ORDER BY name ASC
`, tenantID)
if err != nil {
return nil, fmt.Errorf("failed to list sso configurations: %w", err)
}
defer rows.Close()
var configs []SSOConfig
for rows.Next() {
cfg, err := scanSSOConfig(rows)
if err != nil {
return nil, err
}
configs = append(configs, *cfg)
}
return configs, nil
}
// UpdateConfig updates an existing SSO configuration with partial updates.
func (s *Store) UpdateConfig(ctx context.Context, tenantID, configID uuid.UUID, req *UpdateSSOConfigRequest) (*SSOConfig, error) {
cfg, err := s.GetConfig(ctx, tenantID, configID)
if err != nil {
return nil, err
}
if cfg == nil {
return nil, fmt.Errorf("sso configuration not found")
}
// Apply partial updates
if req.Name != nil {
cfg.Name = *req.Name
}
if req.Enabled != nil {
cfg.Enabled = *req.Enabled
}
if req.OIDCIssuerURL != nil {
cfg.OIDCIssuerURL = *req.OIDCIssuerURL
}
if req.OIDCClientID != nil {
cfg.OIDCClientID = *req.OIDCClientID
}
if req.OIDCClientSecret != nil {
cfg.OIDCClientSecret = *req.OIDCClientSecret
}
if req.OIDCRedirectURI != nil {
cfg.OIDCRedirectURI = *req.OIDCRedirectURI
}
if req.OIDCScopes != nil {
cfg.OIDCScopes = req.OIDCScopes
}
if req.RoleMapping != nil {
cfg.RoleMapping = req.RoleMapping
}
if req.DefaultRoleID != nil {
cfg.DefaultRoleID = req.DefaultRoleID
}
if req.AutoProvision != nil {
cfg.AutoProvision = *req.AutoProvision
}
cfg.UpdatedAt = time.Now().UTC()
roleMappingJSON, err := json.Marshal(cfg.RoleMapping)
if err != nil {
return nil, fmt.Errorf("failed to marshal role_mapping: %w", err)
}
_, err = s.pool.Exec(ctx, `
UPDATE sso_configurations SET
name = $3, enabled = $4,
oidc_issuer_url = $5, oidc_client_id = $6, oidc_client_secret = $7,
oidc_redirect_uri = $8, oidc_scopes = $9,
saml_entity_id = $10, saml_sso_url = $11, saml_certificate = $12, saml_acs_url = $13,
role_mapping = $14, default_role_id = $15, auto_provision = $16,
updated_at = $17
WHERE id = $1 AND tenant_id = $2
`,
cfg.ID, cfg.TenantID,
cfg.Name, cfg.Enabled,
cfg.OIDCIssuerURL, cfg.OIDCClientID, cfg.OIDCClientSecret,
cfg.OIDCRedirectURI, cfg.OIDCScopes,
cfg.SAMLEntityID, cfg.SAMLSSOURL, cfg.SAMLCertificate, cfg.SAMLACS_URL,
roleMappingJSON, cfg.DefaultRoleID, cfg.AutoProvision,
cfg.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to update sso configuration: %w", err)
}
return cfg, nil
}
// DeleteConfig deletes an SSO configuration by ID and tenant.
func (s *Store) DeleteConfig(ctx context.Context, tenantID, configID uuid.UUID) error {
_, err := s.pool.Exec(ctx,
"DELETE FROM sso_configurations WHERE id = $1 AND tenant_id = $2",
configID, tenantID,
)
if err != nil {
return fmt.Errorf("failed to delete sso configuration: %w", err)
}
return nil
}
// GetEnabledConfig retrieves the active/enabled SSO configuration for a tenant.
func (s *Store) GetEnabledConfig(ctx context.Context, tenantID uuid.UUID) (*SSOConfig, error) {
var cfg SSOConfig
var providerType string
var roleMappingJSON []byte
err := s.pool.QueryRow(ctx, `
SELECT
id, tenant_id, provider_type, name, enabled,
oidc_issuer_url, oidc_client_id, oidc_client_secret, oidc_redirect_uri, oidc_scopes,
saml_entity_id, saml_sso_url, saml_certificate, saml_acs_url,
role_mapping, default_role_id, auto_provision,
created_at, updated_at
FROM sso_configurations
WHERE tenant_id = $1 AND enabled = true
LIMIT 1
`, tenantID).Scan(
&cfg.ID, &cfg.TenantID, &providerType, &cfg.Name, &cfg.Enabled,
&cfg.OIDCIssuerURL, &cfg.OIDCClientID, &cfg.OIDCClientSecret, &cfg.OIDCRedirectURI, &cfg.OIDCScopes,
&cfg.SAMLEntityID, &cfg.SAMLSSOURL, &cfg.SAMLCertificate, &cfg.SAMLACS_URL,
&roleMappingJSON, &cfg.DefaultRoleID, &cfg.AutoProvision,
&cfg.CreatedAt, &cfg.UpdatedAt,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get enabled sso configuration: %w", err)
}
cfg.ProviderType = ProviderType(providerType)
cfg.RoleMapping = unmarshalRoleMapping(roleMappingJSON)
return &cfg, nil
}
// ============================================================================
// SSO User Operations
// ============================================================================
// UpsertUser inserts or updates an SSO user via JIT provisioning.
// On conflict (tenant_id, sso_config_id, external_id), the user's email,
// display name, groups, and last login timestamp are updated.
func (s *Store) UpsertUser(ctx context.Context, tenantID, ssoConfigID uuid.UUID, externalID, email, displayName string, groups []string) (*SSOUser, error) {
now := time.Now().UTC()
id := uuid.New()
var user SSOUser
err := s.pool.QueryRow(ctx, `
INSERT INTO sso_users (
id, tenant_id, sso_config_id,
external_id, email, display_name, groups,
last_login, is_active,
created_at, updated_at
) VALUES (
$1, $2, $3,
$4, $5, $6, $7,
$8, true,
$8, $8
)
ON CONFLICT (tenant_id, sso_config_id, external_id) DO UPDATE SET
email = EXCLUDED.email,
display_name = EXCLUDED.display_name,
groups = EXCLUDED.groups,
last_login = EXCLUDED.last_login,
is_active = true,
updated_at = EXCLUDED.updated_at
RETURNING
id, tenant_id, sso_config_id,
external_id, email, display_name, groups,
last_login, is_active,
created_at, updated_at
`,
id, tenantID, ssoConfigID,
externalID, email, displayName, groups,
now,
).Scan(
&user.ID, &user.TenantID, &user.SSOConfigID,
&user.ExternalID, &user.Email, &user.DisplayName, &user.Groups,
&user.LastLogin, &user.IsActive,
&user.CreatedAt, &user.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to upsert sso user: %w", err)
}
return &user, nil
}
// GetUserByExternalID looks up an SSO user by their external identity provider ID.
func (s *Store) GetUserByExternalID(ctx context.Context, tenantID, ssoConfigID uuid.UUID, externalID string) (*SSOUser, error) {
var user SSOUser
err := s.pool.QueryRow(ctx, `
SELECT
id, tenant_id, sso_config_id,
external_id, email, display_name, groups,
last_login, is_active,
created_at, updated_at
FROM sso_users
WHERE tenant_id = $1 AND sso_config_id = $2 AND external_id = $3
`, tenantID, ssoConfigID, externalID).Scan(
&user.ID, &user.TenantID, &user.SSOConfigID,
&user.ExternalID, &user.Email, &user.DisplayName, &user.Groups,
&user.LastLogin, &user.IsActive,
&user.CreatedAt, &user.UpdatedAt,
)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get sso user by external id: %w", err)
}
return &user, nil
}
// ListUsers lists all SSO-provisioned users for a tenant.
func (s *Store) ListUsers(ctx context.Context, tenantID uuid.UUID) ([]SSOUser, error) {
rows, err := s.pool.Query(ctx, `
SELECT
id, tenant_id, sso_config_id,
external_id, email, display_name, groups,
last_login, is_active,
created_at, updated_at
FROM sso_users
WHERE tenant_id = $1
ORDER BY display_name ASC
`, tenantID)
if err != nil {
return nil, fmt.Errorf("failed to list sso users: %w", err)
}
defer rows.Close()
var users []SSOUser
for rows.Next() {
user, err := scanSSOUser(rows)
if err != nil {
return nil, err
}
users = append(users, *user)
}
return users, nil
}
// ============================================================================
// Row Scanning Helpers
// ============================================================================
// scanSSOConfig scans an SSO configuration row from pgx.Rows.
func scanSSOConfig(rows pgx.Rows) (*SSOConfig, error) {
var cfg SSOConfig
var providerType string
var roleMappingJSON []byte
err := rows.Scan(
&cfg.ID, &cfg.TenantID, &providerType, &cfg.Name, &cfg.Enabled,
&cfg.OIDCIssuerURL, &cfg.OIDCClientID, &cfg.OIDCClientSecret, &cfg.OIDCRedirectURI, &cfg.OIDCScopes,
&cfg.SAMLEntityID, &cfg.SAMLSSOURL, &cfg.SAMLCertificate, &cfg.SAMLACS_URL,
&roleMappingJSON, &cfg.DefaultRoleID, &cfg.AutoProvision,
&cfg.CreatedAt, &cfg.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to scan sso configuration: %w", err)
}
cfg.ProviderType = ProviderType(providerType)
cfg.RoleMapping = unmarshalRoleMapping(roleMappingJSON)
return &cfg, nil
}
// scanSSOUser scans an SSO user row from pgx.Rows.
func scanSSOUser(rows pgx.Rows) (*SSOUser, error) {
var user SSOUser
err := rows.Scan(
&user.ID, &user.TenantID, &user.SSOConfigID,
&user.ExternalID, &user.Email, &user.DisplayName, &user.Groups,
&user.LastLogin, &user.IsActive,
&user.CreatedAt, &user.UpdatedAt,
)
if err != nil {
return nil, fmt.Errorf("failed to scan sso user: %w", err)
}
return &user, nil
}
// unmarshalRoleMapping safely unmarshals JSONB role_mapping bytes into a map.
func unmarshalRoleMapping(data []byte) map[string]string {
if data == nil {
return map[string]string{}
}
var m map[string]string
if err := json.Unmarshal(data, &m); err != nil {
return map[string]string{}
}
return m
}