feat: add database-refreshed identity authorization
This commit is contained in:
78
backend/internal/postgres/identity.go
Normal file
78
backend/internal/postgres/identity.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/identity"
|
||||
)
|
||||
|
||||
const FindAuthorizationSnapshotSQL = `SELECT
|
||||
u.id,
|
||||
u.phone,
|
||||
u.display_name,
|
||||
u.role,
|
||||
u.organization_id,
|
||||
u.status,
|
||||
u.session_version,
|
||||
o.id,
|
||||
o.name,
|
||||
o.status
|
||||
FROM public.platform_users AS u
|
||||
LEFT JOIN public.platform_organizations AS o ON o.id = u.organization_id
|
||||
WHERE u.id = $1::text`
|
||||
|
||||
// FindAuthorizationSnapshot loads all database-authoritative identity claims in
|
||||
// one query. Account and organization state are deliberately not filtered so
|
||||
// the Identity module can apply one authorization policy to every result.
|
||||
func (db *Database) FindAuthorizationSnapshot(ctx context.Context, identityKey string) (identity.AuthorizationSnapshot, bool, error) {
|
||||
if db.config.Backend != BackendPostgres || db.querier == nil {
|
||||
return identity.AuthorizationSnapshot{}, false, fmt.Errorf("PostgreSQL is unavailable when ZHINIAN_DATA_BACKEND=%s", db.config.Backend)
|
||||
}
|
||||
rows, err := db.querier.Query(ctx, FindAuthorizationSnapshotSQL, identityKey)
|
||||
if err != nil {
|
||||
return identity.AuthorizationSnapshot{}, false, fmt.Errorf("query authorization snapshot: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
if err := rows.Err(); err != nil {
|
||||
return identity.AuthorizationSnapshot{}, false, fmt.Errorf("read authorization snapshot: %w", err)
|
||||
}
|
||||
return identity.AuthorizationSnapshot{}, false, nil
|
||||
}
|
||||
|
||||
var snapshot identity.AuthorizationSnapshot
|
||||
var organizationID sql.NullString
|
||||
var joinedOrganizationID sql.NullString
|
||||
var organizationName sql.NullString
|
||||
var organizationStatus sql.NullString
|
||||
if err := rows.Scan(
|
||||
&snapshot.Account.ID,
|
||||
&snapshot.Account.Phone,
|
||||
&snapshot.Account.DisplayName,
|
||||
&snapshot.Account.Role,
|
||||
&organizationID,
|
||||
&snapshot.Account.Status,
|
||||
&snapshot.Account.SessionVersion,
|
||||
&joinedOrganizationID,
|
||||
&organizationName,
|
||||
&organizationStatus,
|
||||
); err != nil {
|
||||
return identity.AuthorizationSnapshot{}, false, fmt.Errorf("scan authorization snapshot: %w", err)
|
||||
}
|
||||
if organizationID.Valid {
|
||||
snapshot.Account.OrganizationID = organizationID.String
|
||||
}
|
||||
if joinedOrganizationID.Valid {
|
||||
snapshot.Organization = &identity.OrganizationSnapshot{
|
||||
ID: joinedOrganizationID.String,
|
||||
Name: organizationName.String,
|
||||
Status: organizationStatus.String,
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return identity.AuthorizationSnapshot{}, false, fmt.Errorf("read authorization snapshot: %w", err)
|
||||
}
|
||||
return snapshot, true, nil
|
||||
}
|
||||
206
backend/internal/postgres/identity_test.go
Normal file
206
backend/internal/postgres/identity_test.go
Normal file
@@ -0,0 +1,206 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"git.nianxx.cn/wangxuming/NianAIGC/backend/internal/identity"
|
||||
)
|
||||
|
||||
func TestFindAuthorizationSnapshotLoadsAccountAndOrganizationInOneQuery(t *testing.T) {
|
||||
const wantSQL = `SELECT
|
||||
u.id,
|
||||
u.phone,
|
||||
u.display_name,
|
||||
u.role,
|
||||
u.organization_id,
|
||||
u.status,
|
||||
u.session_version,
|
||||
o.id,
|
||||
o.name,
|
||||
o.status
|
||||
FROM public.platform_users AS u
|
||||
LEFT JOIN public.platform_organizations AS o ON o.id = u.organization_id
|
||||
WHERE u.id = $1::text`
|
||||
rows := &identityRows{rows: [][]any{{
|
||||
"account-1", "13800138000", "Zhang San", "organization_admin", "organization-1", "active", 7,
|
||||
"organization-1", "Acme", "active",
|
||||
}}}
|
||||
querier := &identityQuerier{rows: rows}
|
||||
db := NewDatabase(Config{Backend: BackendPostgres}, querier)
|
||||
|
||||
got, found, err := db.FindAuthorizationSnapshot(context.Background(), "account-1")
|
||||
if err != nil {
|
||||
t.Fatalf("FindAuthorizationSnapshot() error = %v", err)
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("FindAuthorizationSnapshot() found = false, want true")
|
||||
}
|
||||
if querier.sql != wantSQL || !reflect.DeepEqual(querier.args, []any{"account-1"}) {
|
||||
t.Fatalf("query = %q args = %#v, want exact SQL and identity argument", querier.sql, querier.args)
|
||||
}
|
||||
want := identity.AuthorizationSnapshot{
|
||||
Account: identity.AccountSnapshot{
|
||||
ID: "account-1", Phone: "13800138000", DisplayName: "Zhang San", Role: "organization_admin",
|
||||
OrganizationID: "organization-1", Status: "active", SessionVersion: 7,
|
||||
},
|
||||
Organization: &identity.OrganizationSnapshot{ID: "organization-1", Name: "Acme", Status: "active"},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("FindAuthorizationSnapshot() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindAuthorizationSnapshotLoadsUnboundSuperAdminWithNoOrganization(t *testing.T) {
|
||||
querier := &identityQuerier{rows: &identityRows{rows: [][]any{{
|
||||
"super-1", "13900139000", "Root", "super_admin", nil, "active", 4,
|
||||
nil, nil, nil,
|
||||
}}}}
|
||||
db := NewDatabase(Config{Backend: BackendPostgres}, querier)
|
||||
|
||||
got, found, err := db.FindAuthorizationSnapshot(context.Background(), "super-1")
|
||||
if err != nil {
|
||||
t.Fatalf("FindAuthorizationSnapshot() error = %v", err)
|
||||
}
|
||||
if !found || got.Account.OrganizationID != "" || got.Organization != nil {
|
||||
t.Fatalf("FindAuthorizationSnapshot() = (%#v, %v), want unbound account and nil organization", got, found)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindAuthorizationSnapshotReturnsNotFoundForNoAccount(t *testing.T) {
|
||||
db := NewDatabase(Config{Backend: BackendPostgres}, &identityQuerier{rows: &identityRows{}})
|
||||
|
||||
got, found, err := db.FindAuthorizationSnapshot(context.Background(), "missing")
|
||||
if err != nil || found || !reflect.DeepEqual(got, identity.AuthorizationSnapshot{}) {
|
||||
t.Fatalf("FindAuthorizationSnapshot() = (%#v, %v, %v), want zero, false, nil", got, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindAuthorizationSnapshotPropagatesDatabaseFailures(t *testing.T) {
|
||||
queryErr := errors.New("query failed")
|
||||
scanErr := errors.New("scan failed")
|
||||
rowsErr := errors.New("rows failed")
|
||||
tests := []struct {
|
||||
name string
|
||||
querier *identityQuerier
|
||||
wantErr error
|
||||
}{
|
||||
{name: "query", querier: &identityQuerier{err: queryErr}, wantErr: queryErr},
|
||||
{name: "scan", querier: &identityQuerier{rows: &identityRows{rows: [][]any{{nil}}, scanErr: scanErr}}, wantErr: scanErr},
|
||||
{name: "rows before first row", querier: &identityQuerier{rows: &identityRows{err: rowsErr}}, wantErr: rowsErr},
|
||||
{name: "rows after scan", querier: &identityQuerier{rows: &identityRows{
|
||||
rows: [][]any{{"account-1", "13800138000", "Name", "user", "org-1", "active", 1, "org-1", "Org", "active"}},
|
||||
err: rowsErr,
|
||||
}}, wantErr: rowsErr},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
db := NewDatabase(Config{Backend: BackendPostgres}, test.querier)
|
||||
got, found, err := db.FindAuthorizationSnapshot(context.Background(), "account-1")
|
||||
if !errors.Is(err, test.wantErr) {
|
||||
t.Fatalf("FindAuthorizationSnapshot() error = %v, want wrapping %v", err, test.wantErr)
|
||||
}
|
||||
if found || !reflect.DeepEqual(got, identity.AuthorizationSnapshot{}) {
|
||||
t.Fatalf("FindAuthorizationSnapshot() = (%#v, %v), want fail-closed zero result", got, found)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindAuthorizationSnapshotFailsClosedWithoutPostgres(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config Config
|
||||
querier Querier
|
||||
}{
|
||||
{name: "local backend", config: Config{Backend: BackendLocal}, querier: &identityQuerier{err: errors.New("must not query")}},
|
||||
{name: "unavailable pool", config: Config{Backend: BackendPostgres}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
db := NewDatabase(test.config, test.querier)
|
||||
got, found, err := db.FindAuthorizationSnapshot(context.Background(), "account-1")
|
||||
if err == nil || found || !reflect.DeepEqual(got, identity.AuthorizationSnapshot{}) {
|
||||
t.Fatalf("FindAuthorizationSnapshot() = (%#v, %v, %v), want fail-closed error", got, found, err)
|
||||
}
|
||||
if querier, ok := test.querier.(*identityQuerier); ok && querier.called {
|
||||
t.Fatal("FindAuthorizationSnapshot() queried while PostgreSQL was unavailable")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
var _ identity.AuthorizationSnapshotLoader = (*Database)(nil)
|
||||
|
||||
type identityQuerier struct {
|
||||
rows *identityRows
|
||||
err error
|
||||
sql string
|
||||
args []any
|
||||
called bool
|
||||
}
|
||||
|
||||
func (q *identityQuerier) Query(_ context.Context, query string, args ...any) (Rows, error) {
|
||||
q.called = true
|
||||
q.sql = query
|
||||
q.args = args
|
||||
return q.rows, q.err
|
||||
}
|
||||
|
||||
type identityRows struct {
|
||||
rows [][]any
|
||||
idx int
|
||||
err error
|
||||
scanErr error
|
||||
}
|
||||
|
||||
func (r *identityRows) Close() {}
|
||||
func (r *identityRows) Err() error { return r.err }
|
||||
func (r *identityRows) Next() bool { return r.idx < len(r.rows) }
|
||||
|
||||
func (r *identityRows) Scan(dest ...any) error {
|
||||
if r.scanErr != nil {
|
||||
return r.scanErr
|
||||
}
|
||||
if r.idx >= len(r.rows) {
|
||||
return errors.New("scan past end")
|
||||
}
|
||||
row := r.rows[r.idx]
|
||||
r.idx++
|
||||
if len(dest) != len(row) {
|
||||
return errors.New("scan arity mismatch")
|
||||
}
|
||||
for index, target := range dest {
|
||||
value := row[index]
|
||||
switch target := target.(type) {
|
||||
case *string:
|
||||
text, ok := value.(string)
|
||||
if !ok {
|
||||
return errors.New("scan string type mismatch")
|
||||
}
|
||||
*target = text
|
||||
case *int:
|
||||
number, ok := value.(int)
|
||||
if !ok {
|
||||
return errors.New("scan int type mismatch")
|
||||
}
|
||||
*target = number
|
||||
case *sql.NullString:
|
||||
if value == nil {
|
||||
*target = sql.NullString{}
|
||||
continue
|
||||
}
|
||||
text, ok := value.(string)
|
||||
if !ok {
|
||||
return errors.New("scan nullable string type mismatch")
|
||||
}
|
||||
*target = sql.NullString{String: text, Valid: true}
|
||||
default:
|
||||
return errors.New("unsupported scan target")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user