2026-08-26 06:41:07 +08:00
|
|
|
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"`
|
2026-08-29 21:56:51 +08:00
|
|
|
AccountKind string `json:"account_kind"`
|
2026-08-26 06:41:07 +08:00
|
|
|
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,
|
2026-08-29 21:56:51 +08:00
|
|
|
ua.account_kind,
|
2026-08-26 06:41:07 +08:00
|
|
|
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,
|
2026-08-29 21:56:51 +08:00
|
|
|
&account.AccountKind,
|
2026-08-26 06:41:07 +08:00
|
|
|
&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
|
|
|
|
|
}
|