feat: add database-refreshed identity authorization

This commit is contained in:
zn-admin committed 2026-08-13 11:56:36 +08:00
1 parent 716a8031b1
commit c849077591
12 files changed
+1809 -15

No files matched your search

+177
View File
@@ -0,0 +1,177 @@
package identity
import (
"context"
"errors"
"fmt"
"time"
)
// AuthorizationSnapshotLoader is the Identity module's single persistence
// seam. A false found result means that the account does not exist.
type AuthorizationSnapshotLoader interface {
FindAuthorizationSnapshot(context.Context, string) (AuthorizationSnapshot, bool, error)
}
// AuthorizationSnapshot contains all database-authoritative claims needed to
// authorize one signed session.
type AuthorizationSnapshot struct {
Account AccountSnapshot `json:"account"`
Organization *OrganizationSnapshot `json:"organization"`
}
type AccountSnapshot struct {
ID string `json:"id"`
Phone string `json:"phone"`
DisplayName string `json:"displayName"`
Role string `json:"role"`
OrganizationID string `json:"organizationId,omitempty"`
Status string `json:"status"`
SessionVersion int `json:"sessionVersion"`
}
type OrganizationSnapshot struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
}
type RejectionReason string
const (
RejectionInvalidSession RejectionReason = "invalid_session"
RejectionClientMismatch RejectionReason = "client_mismatch"
RejectionAccountNotFound RejectionReason = "account_not_found"
RejectionAccountDisabled RejectionReason = "account_disabled"
RejectionSessionVersionMismatch RejectionReason = "session_version_mismatch"
RejectionOrganizationRequired RejectionReason = "organization_required"
RejectionOrganizationNotActive RejectionReason = "organization_not_active"
RejectionInvalidRole RejectionReason = "invalid_role"
)
var ErrUnauthenticated = errors.New("unauthenticated")
// UnauthenticatedError retains a diagnostic reason while allowing callers to
// collapse all authorization denials with errors.Is(err, ErrUnauthenticated).
type UnauthenticatedError struct {
Reason RejectionReason
}
func (err *UnauthenticatedError) Error() string {
return fmt.Sprintf("%s: %s", ErrUnauthenticated, err.Reason)
}
func (err *UnauthenticatedError) Unwrap() error {
return ErrUnauthenticated
}
type Resolver struct {
loader AuthorizationSnapshotLoader
secret string
requiredClientID string
now func() time.Time
}
func NewResolver(loader AuthorizationSnapshotLoader, secret, requiredClientID string, now func() time.Time) *Resolver {
if now == nil {
now = time.Now
}
if requiredClientID == "" {
requiredClientID = "platform"
}
return &Resolver{
loader: loader,
secret: secret,
requiredClientID: requiredClientID,
now: now,
}
}
// Resolve authenticates the signed cookie, reloads its account authorization
// state, and returns a session whose authorization claims all come from the
// database snapshot.
func (resolver *Resolver) Resolve(ctx context.Context, cookieValue string) (Session, error) {
if resolver == nil || resolver.loader == nil || resolver.secret == "" {
return Session{}, fmt.Errorf("identity resolver is not configured")
}
session, err := Parse(cookieValue, resolver.secret, resolver.now())
if err != nil {
return Session{}, reject(RejectionInvalidSession)
}
if session.User.ClientID != resolver.requiredClientID {
return Session{}, reject(RejectionClientMismatch)
}
snapshot, found, err := resolver.loader.FindAuthorizationSnapshot(ctx, session.User.ID)
if err != nil {
return Session{}, err
}
if !found {
return Session{}, reject(RejectionAccountNotFound)
}
account := snapshot.Account
if account.Status != "active" {
return Session{}, reject(RejectionAccountDisabled)
}
if session.SessionVersion != nil && *session.SessionVersion != 0 && *session.SessionVersion != account.SessionVersion {
return Session{}, reject(RejectionSessionVersionMismatch)
}
authMode, authorities, validRole := roleClaims(account.Role)
if !validRole {
return Session{}, reject(RejectionInvalidRole)
}
if account.Role != "super_admin" {
if account.OrganizationID == "" {
return Session{}, reject(RejectionOrganizationRequired)
}
if snapshot.Organization == nil || snapshot.Organization.ID != account.OrganizationID || snapshot.Organization.Status != "active" {
return Session{}, reject(RejectionOrganizationNotActive)
}
}
currentVersion := account.SessionVersion
resolved := Session{
Version: session.Version,
AuthMode: authMode,
IssuedAt: session.IssuedAt,
ExpiresAt: session.ExpiresAt,
SessionVersion: &currentVersion,
AccessToken: session.AccessToken,
TokenType: session.TokenType,
User: User{
ID: account.ID,
Subject: account.ID,
Username: account.Phone,
Phone: account.Phone,
DisplayName: account.DisplayName,
ClientID: resolver.requiredClientID,
OrganizationID: account.OrganizationID,
Role: account.Role,
Status: account.Status,
Authorities: authorities,
Scope: []string{},
},
}
if snapshot.Organization != nil && snapshot.Organization.ID == account.OrganizationID {
resolved.User.OrganizationName = snapshot.Organization.Name
}
return resolved, nil
}
func roleClaims(role string) (AuthMode, []string, bool) {
switch role {
case "user":
return AuthModeUser, []string{"ROLE_USER"}, true
case "organization_admin":
return AuthModeAdmin, []string{"ROLE_ORGANIZATION_ADMIN", "ORGANIZATION_ADMIN"}, true
case "super_admin":
return AuthModeAdmin, []string{"ROLE_SUPER_ADMIN", "SUPER_ADMIN"}, true
default:
return "", nil, false
}
}
func reject(reason RejectionReason) error {
return &UnauthenticatedError{Reason: reason}
}
+195
View File
@@ -0,0 +1,195 @@
package identity
import (
"context"
"encoding/json"
"errors"
"os"
"reflect"
"testing"
"time"
)
type authorizationFixture struct {
RequiredClientID string `json:"requiredClientId"`
SessionSecret string `json:"sessionSecret"`
NowUnix int64 `json:"nowUnix"`
Cases []struct {
Name string `json:"name"`
Session Session `json:"session"`
Snapshot *AuthorizationSnapshot `json:"snapshot"`
Expected struct {
Outcome string `json:"outcome"`
Reason RejectionReason `json:"reason"`
LoaderCalls int `json:"loaderCalls"`
Session Session `json:"session"`
} `json:"expected"`
} `json:"cases"`
}
type recordingAuthorizationLoader struct {
snapshot AuthorizationSnapshot
found bool
err error
ids []string
}
func (loader *recordingAuthorizationLoader) FindAuthorizationSnapshot(_ context.Context, id string) (AuthorizationSnapshot, bool, error) {
loader.ids = append(loader.ids, id)
return loader.snapshot, loader.found, loader.err
}
func TestResolverDrivesPlatformAuthorizationContract(t *testing.T) {
fixture := loadAuthorizationFixture(t)
if len(fixture.Cases) != 14 {
t.Fatalf("authorization fixture cases = %d, want 14", len(fixture.Cases))
}
for _, testCase := range fixture.Cases {
t.Run(testCase.Name, func(t *testing.T) {
cookie, err := json.Marshal(testCase.Session)
if err != nil {
t.Fatalf("marshal session fixture: %v", err)
}
signed, err := Sign(cookie, fixture.SessionSecret)
if err != nil {
t.Fatalf("sign session fixture: %v", err)
}
loader := &recordingAuthorizationLoader{}
if testCase.Snapshot != nil {
loader.snapshot = *testCase.Snapshot
loader.found = true
}
resolver := NewResolver(loader, fixture.SessionSecret, fixture.RequiredClientID, func() time.Time {
return time.Unix(fixture.NowUnix, 0)
})
got, resolveErr := resolver.Resolve(context.Background(), signed)
if len(loader.ids) != testCase.Expected.LoaderCalls {
t.Fatalf("loader calls = %d, want %d", len(loader.ids), testCase.Expected.LoaderCalls)
}
if len(loader.ids) == 1 && loader.ids[0] != testCase.Session.User.ID {
t.Fatalf("loader ID = %q, want %q", loader.ids[0], testCase.Session.User.ID)
}
switch testCase.Expected.Outcome {
case "authenticated":
if resolveErr != nil {
t.Fatalf("Resolve() error = %v", resolveErr)
}
if !reflect.DeepEqual(got, testCase.Expected.Session) {
t.Errorf("resolved session mismatch\n got: %#v\nwant: %#v", got, testCase.Expected.Session)
}
case "unauthenticated":
var rejection *UnauthenticatedError
if !errors.As(resolveErr, &rejection) {
t.Fatalf("Resolve() error = %v, want typed unauthenticated rejection", resolveErr)
}
if !errors.Is(resolveErr, ErrUnauthenticated) {
t.Errorf("Resolve() must collapse to ErrUnauthenticated")
}
if rejection.Reason != testCase.Expected.Reason {
t.Errorf("rejection reason = %q, want %q", rejection.Reason, testCase.Expected.Reason)
}
default:
t.Fatalf("unsupported fixture outcome %q", testCase.Expected.Outcome)
}
})
}
}
func TestResolverPropagatesLoaderError(t *testing.T) {
fixture := loadAuthorizationFixture(t)
databaseErr := errors.New("database unavailable")
loader := &recordingAuthorizationLoader{err: databaseErr}
resolver := NewResolver(loader, fixture.SessionSecret, fixture.RequiredClientID, func() time.Time {
return time.Unix(fixture.NowUnix, 0)
})
signed := signFixtureSession(t, fixture.Cases[0].Session, fixture.SessionSecret)
_, err := resolver.Resolve(context.Background(), signed)
if !errors.Is(err, databaseErr) {
t.Fatalf("Resolve() error = %v, want database error", err)
}
if errors.Is(err, ErrUnauthenticated) {
t.Fatal("database error must not collapse to unauthenticated")
}
if !reflect.DeepEqual(loader.ids, []string{fixture.Cases[0].Session.User.ID}) {
t.Fatalf("loader IDs = %#v", loader.ids)
}
}
func TestResolverRejectsInvalidCookieWithoutLoadingAuthorization(t *testing.T) {
fixture := loadAuthorizationFixture(t)
loader := &recordingAuthorizationLoader{}
resolver := NewResolver(loader, fixture.SessionSecret, fixture.RequiredClientID, func() time.Time {
return time.Unix(fixture.NowUnix, 0)
})
_, err := resolver.Resolve(context.Background(), "not-a-signed-session")
var rejection *UnauthenticatedError
if !errors.As(err, &rejection) || rejection.Reason != RejectionInvalidSession {
t.Fatalf("Resolve() error = %v, want invalid-session rejection", err)
}
if len(loader.ids) != 0 {
t.Fatalf("loader calls = %d, want 0", len(loader.ids))
}
}
func TestNewResolverDefaultsRequiredPlatformClient(t *testing.T) {
fixture := loadAuthorizationFixture(t)
loader := &recordingAuthorizationLoader{}
resolver := NewResolver(loader, fixture.SessionSecret, "", func() time.Time {
return time.Unix(fixture.NowUnix, 0)
})
signed := signFixtureSession(t, fixture.Cases[5].Session, fixture.SessionSecret)
_, err := resolver.Resolve(context.Background(), signed)
var rejection *UnauthenticatedError
if !errors.As(err, &rejection) || rejection.Reason != RejectionClientMismatch {
t.Fatalf("Resolve() error = %v, want client mismatch with default platform client", err)
}
if len(loader.ids) != 0 {
t.Fatalf("loader calls = %d, want 0", len(loader.ids))
}
}
func TestResolverFailsClosedWhenNotConfigured(t *testing.T) {
for _, resolver := range []*Resolver{
nil,
NewResolver(nil, "secret", "platform", nil),
NewResolver(&recordingAuthorizationLoader{}, "", "platform", nil),
} {
_, err := resolver.Resolve(context.Background(), "cookie")
if err == nil || errors.Is(err, ErrUnauthenticated) {
t.Fatalf("Resolve() error = %v, want configuration failure", err)
}
}
}
func loadAuthorizationFixture(t *testing.T) authorizationFixture {
t.Helper()
raw, err := os.ReadFile("../../../contracts/auth/platform-session-authorization-v1.json")
if err != nil {
t.Fatalf("read authorization fixture: %v", err)
}
var fixture authorizationFixture
if err := json.Unmarshal(raw, &fixture); err != nil {
t.Fatalf("decode authorization fixture: %v", err)
}
return fixture
}
func signFixtureSession(t *testing.T, session Session, secret string) string {
t.Helper()
raw, err := json.Marshal(session)
if err != nil {
t.Fatalf("marshal fixture session: %v", err)
}
signed, err := Sign(raw, secret)
if err != nil {
t.Fatalf("sign fixture session: %v", err)
}
return signed
}