Files
story/internal/identity/repository.go
T
2026-08-29 21:56:51 +08:00

174 lines
5.3 KiB
Go

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
}