chore: establish local platform baseline

This commit is contained in:
Codex Local
2026-08-26 06:41:54 +08:00
commit 29bdf4900f
575 changed files with 95644 additions and 0 deletions
+79
View File
@@ -0,0 +1,79 @@
package identity
import "sort"
type Role string
type Capability string
const (
RoleOwner Role = "owner"
RoleAdmin Role = "admin"
RoleEditor Role = "editor"
RoleViewer Role = "viewer"
)
const (
CapabilityHouseholdRead Capability = "household.read"
CapabilityHouseholdManage Capability = "household.manage"
CapabilityMemberManage Capability = "member.manage"
CapabilityOwnershipTransfer Capability = "ownership.transfer"
CapabilitySubjectRead Capability = "subject.read"
CapabilitySubjectWrite Capability = "subject.write"
CapabilityConversationRead Capability = "conversation.read"
CapabilityStoryReview Capability = "story.review"
CapabilityDeviceManage Capability = "device.manage"
CapabilityFirmwareManage Capability = "firmware.manage"
CapabilityRecordingPlay Capability = "recording.play"
CapabilityRecordingDownload Capability = "recording.download"
CapabilityPrivacyRead Capability = "privacy.read"
CapabilityPrivacyManage Capability = "privacy.manage"
)
var roleCapabilities = map[Role]map[Capability]struct{}{
RoleOwner: capabilitySet(
CapabilityHouseholdRead, CapabilityHouseholdManage, CapabilityMemberManage,
CapabilityOwnershipTransfer, CapabilitySubjectRead, CapabilitySubjectWrite,
CapabilityConversationRead, CapabilityStoryReview, CapabilityDeviceManage,
CapabilityFirmwareManage, CapabilityPrivacyRead, CapabilityPrivacyManage,
),
RoleAdmin: capabilitySet(
CapabilityHouseholdRead, CapabilityHouseholdManage, CapabilityMemberManage,
CapabilitySubjectRead, CapabilitySubjectWrite, CapabilityConversationRead,
CapabilityStoryReview, CapabilityDeviceManage,
CapabilityFirmwareManage, CapabilityPrivacyRead,
),
RoleEditor: capabilitySet(
CapabilityHouseholdRead, CapabilitySubjectRead, CapabilitySubjectWrite,
CapabilityConversationRead, CapabilityStoryReview, CapabilityDeviceManage,
),
RoleViewer: capabilitySet(
CapabilityHouseholdRead, CapabilitySubjectRead, CapabilityConversationRead,
),
}
func Can(role Role, capability Capability) bool {
capabilities, ok := roleCapabilities[role]
if !ok {
return false
}
_, ok = capabilities[capability]
return ok
}
func CapabilitiesForRole(role Role) []Capability {
set := roleCapabilities[role]
result := make([]Capability, 0, len(set))
for capability := range set {
result = append(result, capability)
}
sort.Slice(result, func(i, j int) bool { return result[i] < result[j] })
return result
}
func capabilitySet(capabilities ...Capability) map[Capability]struct{} {
set := make(map[Capability]struct{}, len(capabilities))
for _, capability := range capabilities {
set[capability] = struct{}{}
}
return set
}
+35
View File
@@ -0,0 +1,35 @@
package identity
import "testing"
func TestRoleCapabilityMatrix(t *testing.T) {
tests := []struct {
role Role
capability Capability
allowed bool
}{
{RoleOwner, CapabilityMemberManage, true},
{RoleAdmin, CapabilityMemberManage, true},
{RoleEditor, CapabilityMemberManage, false},
{RoleViewer, CapabilitySubjectWrite, false},
{RoleViewer, CapabilityConversationRead, true},
{RoleAdmin, CapabilityOwnershipTransfer, false},
{RoleOwner, CapabilityPrivacyManage, true},
{RoleAdmin, CapabilityPrivacyRead, true},
{RoleAdmin, CapabilityPrivacyManage, false},
{RoleEditor, CapabilityPrivacyRead, false},
}
for _, test := range tests {
if actual := Can(test.role, test.capability); actual != test.allowed {
t.Errorf("Can(%s, %s) = %t, want %t", test.role, test.capability, actual, test.allowed)
}
}
}
func TestHouseholdRoleNeverGrantsRecordingAccess(t *testing.T) {
for _, role := range []Role{RoleOwner, RoleAdmin, RoleEditor, RoleViewer} {
if Can(role, CapabilityRecordingPlay) || Can(role, CapabilityRecordingDownload) {
t.Fatalf("role %s must not implicitly grant recording access", role)
}
}
}
+21
View File
@@ -0,0 +1,21 @@
package identity
import "context"
type Principal struct {
Issuer string `json:"issuer"`
Subject string `json:"subject"`
DisplayName string `json:"display_name,omitempty"`
PreferredUsername string `json:"preferred_username,omitempty"`
}
type principalContextKey struct{}
func WithPrincipal(ctx context.Context, principal Principal) context.Context {
return context.WithValue(ctx, principalContextKey{}, principal)
}
func PrincipalFromContext(ctx context.Context) (Principal, bool) {
principal, ok := ctx.Value(principalContextKey{}).(Principal)
return principal, ok
}
+17
View File
@@ -0,0 +1,17 @@
package identity
import (
"context"
"testing"
)
func TestPrincipalContext(t *testing.T) {
if _, ok := PrincipalFromContext(context.Background()); ok {
t.Fatal("empty context should not contain a principal")
}
expected := Principal{Issuer: "issuer", Subject: "subject"}
actual, ok := PrincipalFromContext(WithPrincipal(context.Background(), expected))
if !ok || actual != expected {
t.Fatalf("principal = %#v, %t, want %#v", actual, ok, expected)
}
}
+170
View File
@@ -0,0 +1,170 @@
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"`
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,
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.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
}
@@ -0,0 +1,61 @@
package identity
import (
"context"
"errors"
"os"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
const (
localIssuer = "http://localhost:58180/realms/voice-local"
ownerSubject = "00000000-0000-0000-0000-000000000101"
outsiderSubject = "00000000-0000-0000-0000-000000000103"
firstHouseholdID = "10000000-0000-0000-0000-000000000211"
secondHouseholdID = "20000000-0000-0000-0000-000000000211"
)
func TestRepositoryEnforcesTenantAndRoleBoundaries(t *testing.T) {
databaseURL := os.Getenv("TEST_DATABASE_URL")
if databaseURL == "" {
t.Skip("TEST_DATABASE_URL is not set")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, databaseURL)
if err != nil {
t.Fatalf("connect integration database: %v", err)
}
defer pool.Close()
repository := NewRepository(pool)
owner := Principal{Issuer: localIssuer, Subject: ownerSubject}
currentUser, err := repository.CurrentUser(ctx, owner)
if err != nil {
t.Fatalf("load owner: %v", err)
}
if len(currentUser.Accounts) != 1 || len(currentUser.Accounts[0].Memberships) != 1 {
t.Fatalf("unexpected owner account: %#v", currentUser)
}
if currentUser.Accounts[0].Memberships[0].Role != RoleOwner {
t.Fatalf("owner role not resolved: %#v", currentUser.Accounts[0].Memberships[0])
}
if _, err := repository.RequireHouseholdCapability(ctx, owner, firstHouseholdID, CapabilityHouseholdManage); err != nil {
t.Fatalf("owner should manage own household: %v", err)
}
outsider := Principal{Issuer: localIssuer, Subject: outsiderSubject}
if _, err := repository.RequireHouseholdCapability(ctx, outsider, firstHouseholdID, CapabilityHouseholdRead); !errors.Is(err, ErrAccessDenied) {
t.Fatalf("cross-tenant access error = %v, want ErrAccessDenied", err)
}
if _, err := repository.RequireHouseholdCapability(ctx, outsider, secondHouseholdID, CapabilityHouseholdRead); err != nil {
t.Fatalf("viewer should read own household: %v", err)
}
if _, err := repository.RequireHouseholdCapability(ctx, outsider, secondHouseholdID, CapabilityHouseholdManage); !errors.Is(err, ErrAccessDenied) {
t.Fatalf("viewer manage error = %v, want ErrAccessDenied", err)
}
if _, err := repository.RequireHouseholdCapability(ctx, owner, firstHouseholdID, CapabilityRecordingPlay); !errors.Is(err, ErrAccessDenied) {
t.Fatalf("owner recording access error = %v, want ErrAccessDenied", err)
}
}
+53
View File
@@ -0,0 +1,53 @@
package identity
import (
"context"
"errors"
"strings"
"github.com/coreos/go-oidc/v3/oidc"
)
var ErrInvalidToken = errors.New("invalid access token")
type TokenVerifier interface {
Verify(context.Context, string) (Principal, error)
}
type OIDCVerifier struct {
verifier *oidc.IDTokenVerifier
}
func NewOIDCVerifier(ctx context.Context, issuerURL, jwksURL, audience string) *OIDCVerifier {
keySet := oidc.NewRemoteKeySet(ctx, jwksURL)
return &OIDCVerifier{
verifier: oidc.NewVerifier(issuerURL, keySet, &oidc.Config{ClientID: audience}),
}
}
func (v *OIDCVerifier) Verify(ctx context.Context, rawToken string) (Principal, error) {
rawToken = strings.TrimSpace(rawToken)
if rawToken == "" {
return Principal{}, ErrInvalidToken
}
token, err := v.verifier.Verify(ctx, rawToken)
if err != nil {
return Principal{}, ErrInvalidToken
}
var claims struct {
Name string `json:"name"`
PreferredUsername string `json:"preferred_username"`
}
if err := token.Claims(&claims); err != nil {
return Principal{}, ErrInvalidToken
}
if token.Issuer == "" || token.Subject == "" {
return Principal{}, ErrInvalidToken
}
return Principal{
Issuer: token.Issuer,
Subject: token.Subject,
DisplayName: claims.Name,
PreferredUsername: claims.PreferredUsername,
}, nil
}
+88
View File
@@ -0,0 +1,88 @@
package identity
import (
"context"
"crypto/rand"
"crypto/rsa"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-jose/go-jose/v4"
josejwt "github.com/go-jose/go-jose/v4/jwt"
)
func TestOIDCVerifierAcceptsSignedToken(t *testing.T) {
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
keyID := "test-key"
webKey := jose.JSONWebKey{Key: &privateKey.PublicKey, KeyID: keyID, Algorithm: string(jose.RS256), Use: "sig"}
keySet := jose.JSONWebKeySet{Keys: []jose.JSONWebKey{webKey}}
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
writer.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(writer).Encode(keySet)
}))
defer server.Close()
issuer := "https://identity.example.test/realms/test"
audience := "voice-platform-web"
rawToken := signTestToken(t, privateKey, keyID, issuer, audience)
verifier := NewOIDCVerifier(context.Background(), issuer, server.URL, audience)
principal, err := verifier.Verify(context.Background(), rawToken)
if err != nil {
t.Fatalf("verify token: %v", err)
}
if principal.Issuer != issuer || principal.Subject != "user-123" || principal.PreferredUsername != "tester" {
t.Fatalf("unexpected principal: %#v", principal)
}
}
func TestOIDCVerifierRejectsEmptyAndWrongAudience(t *testing.T) {
verifier := &OIDCVerifier{}
if _, err := verifier.Verify(context.Background(), ""); err != ErrInvalidToken {
t.Fatalf("empty token error = %v", err)
}
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
keyID := "test-key"
keySet := jose.JSONWebKeySet{Keys: []jose.JSONWebKey{{
Key: &privateKey.PublicKey, KeyID: keyID, Algorithm: string(jose.RS256), Use: "sig",
}}}
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(writer).Encode(keySet)
}))
defer server.Close()
issuer := "https://identity.example.test/realms/test"
verifier = NewOIDCVerifier(context.Background(), issuer, server.URL, "expected-audience")
if _, err := verifier.Verify(context.Background(), signTestToken(t, privateKey, keyID, issuer, "wrong-audience")); err != ErrInvalidToken {
t.Fatalf("wrong audience error = %v", err)
}
}
func signTestToken(t *testing.T, key *rsa.PrivateKey, keyID, issuer, audience string) string {
t.Helper()
options := (&jose.SignerOptions{}).WithType("JWT").WithHeader("kid", keyID)
signer, err := jose.NewSigner(jose.SigningKey{Algorithm: jose.RS256, Key: key}, options)
if err != nil {
t.Fatal(err)
}
rawToken, err := josejwt.Signed(signer).
Claims(josejwt.Claims{
Issuer: issuer, Subject: "user-123", Audience: josejwt.Audience{audience},
Expiry: josejwt.NewNumericDate(time.Now().Add(time.Minute)),
IssuedAt: josejwt.NewNumericDate(time.Now()),
}).
Claims(map[string]any{"name": "测试用户", "preferred_username": "tester"}).
Serialize()
if err != nil {
t.Fatal(err)
}
return rawToken
}