package identity import ( "context" "encoding/json" "errors" "fmt" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) var ( ErrAccountNotFound = errors.New("platform account not found") ErrAccessDenied = errors.New("resource access denied") ) type HouseholdMembership struct { HouseholdID string `json:"household_id"` HouseholdName string `json:"household_name"` Role Role `json:"role"` Capabilities []Capability `json:"capabilities"` } type Account struct { UserID string `json:"user_id"` TenantID string `json:"tenant_id"` TenantName string `json:"tenant_name"` PolicyVersion string `json:"policy_version"` PlatformRole string `json:"platform_role"` AccountKind string `json:"account_kind"` DisplayName string `json:"display_name"` Memberships []HouseholdMembership `json:"household_memberships"` } type CurrentUser struct { Identity Principal `json:"identity"` Accounts []Account `json:"accounts"` } type HouseholdAccess struct { TenantID string `json:"tenant_id"` UserID string `json:"user_id"` HouseholdID string `json:"household_id"` Role Role `json:"role"` } type Repository struct { pool *pgxpool.Pool } func NewRepository(pool *pgxpool.Pool) *Repository { return &Repository{pool: pool} } func (r *Repository) CurrentUser(ctx context.Context, principal Principal) (CurrentUser, error) { rows, err := r.pool.Query(ctx, ` SELECT ua.public_id::text, t.public_id::text, t.name, t.policy_version, ua.platform_role, ua.account_kind, COALESCE(ua.display_name, ''), COALESCE( jsonb_agg( jsonb_build_object( 'household_id', h.public_id::text, 'household_name', h.name, 'role', hm.role ) ORDER BY h.name ) FILTER (WHERE hm.id IS NOT NULL), '[]'::jsonb ) FROM user_account ua JOIN tenant t ON t.id = ua.tenant_id AND t.status = 'active' LEFT JOIN household_member hm ON hm.user_id = ua.id AND hm.tenant_id = ua.tenant_id AND hm.status = 'active' LEFT JOIN household h ON h.id = hm.household_id AND h.tenant_id = hm.tenant_id AND h.status = 'active' WHERE ua.oidc_issuer = $1 AND ua.oidc_subject = $2 AND ua.status = 'active' GROUP BY ua.id, ua.public_id, t.public_id, t.name, t.policy_version, ua.display_name ORDER BY t.name`, principal.Issuer, principal.Subject) if err != nil { return CurrentUser{}, fmt.Errorf("query current user: %w", err) } defer rows.Close() accounts := make([]Account, 0, 1) for rows.Next() { var account Account var membershipsJSON []byte if err := rows.Scan( &account.UserID, &account.TenantID, &account.TenantName, &account.PolicyVersion, &account.PlatformRole, &account.AccountKind, &account.DisplayName, &membershipsJSON, ); err != nil { return CurrentUser{}, fmt.Errorf("scan current user: %w", err) } var rawMemberships []struct { HouseholdID string `json:"household_id"` HouseholdName string `json:"household_name"` Role Role `json:"role"` } if err := json.Unmarshal(membershipsJSON, &rawMemberships); err != nil { return CurrentUser{}, fmt.Errorf("decode household memberships: %w", err) } account.Memberships = make([]HouseholdMembership, 0, len(rawMemberships)) for _, membership := range rawMemberships { account.Memberships = append(account.Memberships, HouseholdMembership{ HouseholdID: membership.HouseholdID, HouseholdName: membership.HouseholdName, Role: membership.Role, Capabilities: CapabilitiesForRole(membership.Role), }) } accounts = append(accounts, account) } if err := rows.Err(); err != nil { return CurrentUser{}, fmt.Errorf("iterate current user: %w", err) } if len(accounts) == 0 { return CurrentUser{}, ErrAccountNotFound } if _, err := r.pool.Exec(ctx, ` UPDATE user_account SET last_login_at = now(), updated_at = now() WHERE oidc_issuer = $1 AND oidc_subject = $2 AND status = 'active'`, principal.Issuer, principal.Subject, ); err != nil { return CurrentUser{}, fmt.Errorf("update last login: %w", err) } return CurrentUser{Identity: principal, Accounts: accounts}, nil } func (r *Repository) RequireHouseholdCapability( ctx context.Context, principal Principal, householdPublicID string, capability Capability, ) (HouseholdAccess, error) { var access HouseholdAccess var role string err := r.pool.QueryRow(ctx, ` SELECT t.public_id::text, ua.public_id::text, h.public_id::text, hm.role FROM household h JOIN tenant t ON t.id = h.tenant_id AND t.status = 'active' JOIN household_member hm ON hm.household_id = h.id AND hm.tenant_id = h.tenant_id AND hm.status = 'active' JOIN user_account ua ON ua.id = hm.user_id AND ua.tenant_id = h.tenant_id AND ua.status = 'active' WHERE h.public_id = $1::uuid AND h.status = 'active' AND ua.oidc_issuer = $2 AND ua.oidc_subject = $3`, householdPublicID, principal.Issuer, principal.Subject, ).Scan(&access.TenantID, &access.UserID, &access.HouseholdID, &role) if errors.Is(err, pgx.ErrNoRows) { return HouseholdAccess{}, ErrAccessDenied } if err != nil { return HouseholdAccess{}, fmt.Errorf("resolve household access: %w", err) } access.Role = Role(role) if !Can(access.Role, capability) { return HouseholdAccess{}, ErrAccessDenied } return access, nil }