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>
478 lines
14 KiB
Go
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
|
|
}
|