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>
This commit is contained in:
477
ai-compliance-sdk/internal/sso/store.go
Normal file
477
ai-compliance-sdk/internal/sso/store.go
Normal file
@@ -0,0 +1,477 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user