Files
Agent-System/internal/auth/pg_store.go
T
weichenandCursor 6219caf690 feat(authz): complete Phase 1 admin roles, sessions, and unified admin gate
Add roles CRUD backed by system_roles tables, session list/revoke APIs,
capabilities fallback for memory mode, and route all admin checks through
AuthorizationService instead of hardcoded role-admin checks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-13 02:50:22 +00:00

442 lines
12 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package auth
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"chat-agent-system/internal/config"
)
// PgStore PostgreSQL 实现的账户、凭证与会话存储。
type PgStore struct {
pool *pgxpool.Pool
cfg *config.Config
}
// NewPgStore 构造 PG AuthStore;pool 必填且 IAM 表已迁移。
func NewPgStore(pool *pgxpool.Pool, cfg *config.Config) (*PgStore, error) {
if pool == nil {
return nil, errors.New("pg auth store: pool required")
}
return &PgStore{pool: pool, cfg: cfg}, nil
}
// IAMTablesReady 检查 Phase 1 IAM 表是否可用。
func IAMTablesReady(ctx context.Context, pool *pgxpool.Pool) bool {
if pool == nil {
return false
}
var ok bool
err := pool.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'user_credentials'
)
`).Scan(&ok)
return err == nil && ok
}
func (s *PgStore) scanUser(row pgx.Row) (*UserAccount, error) {
var u UserAccount
var passwordHash string
var createdAt, updatedAt time.Time
err := row.Scan(
&u.ID,
&u.Username,
&u.Name,
&passwordHash,
&u.Status,
&u.MustChangePassword,
&u.RoleID,
&u.Position,
&u.Department,
&u.EmployeeID,
&u.Email,
&createdAt,
&updatedAt,
)
if err != nil {
return nil, err
}
u.PasswordHash = passwordHash
u.CreatedAt = createdAt.Format("2006-01-02 15:04:05")
u.UpdatedAt = updatedAt.Format("2006-01-02 15:04:05")
return &u, nil
}
const userSelectSQL = `
SELECT u.id,
COALESCE(NULLIF(u.username, ''), u.account_id) AS username,
u.name,
COALESCE(uc.password_hash, '') AS password_hash,
u.status,
u.must_change_password,
u.role_id,
u.position,
u.department,
u.employee_id,
u.email,
u.created_at,
u.updated_at
FROM users u
LEFT JOIN user_credentials uc ON uc.user_id = u.id
`
func (s *PgStore) GetUserByUsername(ctx context.Context, username string) (*UserAccount, error) {
username = strings.TrimSpace(username)
row := s.pool.QueryRow(ctx, userSelectSQL+`
WHERE lower(COALESCE(NULLIF(u.username, ''), u.account_id)) = lower($1)
LIMIT 1
`, username)
u, err := s.scanUser(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, fmt.Errorf("user not found: %s", username)
}
return nil, err
}
return u, nil
}
func (s *PgStore) GetUserByID(ctx context.Context, id int64) (*UserAccount, error) {
row := s.pool.QueryRow(ctx, userSelectSQL+` WHERE u.id = $1`, id)
u, err := s.scanUser(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, fmt.Errorf("user not found: %d", id)
}
return nil, err
}
return u, nil
}
func (s *PgStore) CreateUser(ctx context.Context, user *UserAccount) (*UserAccount, error) {
if user == nil || strings.TrimSpace(user.Username) == "" {
return nil, errors.New("username required")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
roleLegacy := "engineer"
switch user.RoleID {
case "role-director":
roleLegacy = "director"
case "role-tech-lead":
roleLegacy = "regulatory"
case "role-guest":
roleLegacy = "sales"
}
var id int64
err = tx.QueryRow(ctx, `
INSERT INTO users (
account_id, name, role, username, status, must_change_password,
role_id, position, department, employee_id, email
) VALUES ($1, $2, $3, $1, $4, $5, $6, $7, $8, $9, $10)
RETURNING id
`, user.Username, user.Name, roleLegacy, user.Status, user.MustChangePassword,
user.RoleID, user.Position, user.Department, user.EmployeeID, user.Email).Scan(&id)
if err != nil {
return nil, fmt.Errorf("insert user: %w", err)
}
if user.PasswordHash != "" {
_, err = tx.Exec(ctx, `
INSERT INTO user_credentials (user_id, password_hash)
VALUES ($1, $2)
`, id, user.PasswordHash)
if err != nil {
return nil, fmt.Errorf("insert credentials: %w", err)
}
}
if user.RoleID != "" {
_, _ = tx.Exec(ctx, `
INSERT INTO user_role_bindings (user_id, role_id, scope_type, scope_id, assigned_by)
VALUES ($1, $2, 'system', 0, $1)
ON CONFLICT DO NOTHING
`, id, user.RoleID)
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return s.GetUserByID(ctx, id)
}
func (s *PgStore) UpdateUser(ctx context.Context, user *UserAccount) (*UserAccount, error) {
_, err := s.pool.Exec(ctx, `
UPDATE users SET
name = $2,
department = $3,
employee_id = $4,
email = $5,
updated_at = now()
WHERE id = $1
`, user.ID, user.Name, user.Department, user.EmployeeID, user.Email)
if err != nil {
return nil, err
}
return s.GetUserByID(ctx, user.ID)
}
func (s *PgStore) UpdateUserRole(ctx context.Context, id int64, roleID, position string) (*UserAccount, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
_, err = tx.Exec(ctx, `
UPDATE users SET role_id = $2, position = $3, updated_at = now() WHERE id = $1
`, id, roleID, position)
if err != nil {
return nil, err
}
_, err = tx.Exec(ctx, `
DELETE FROM user_role_bindings
WHERE user_id = $1 AND scope_type = 'system' AND scope_id = 0 AND role_id <> $2
`, id, roleID)
if err != nil {
return nil, err
}
_, err = tx.Exec(ctx, `
INSERT INTO user_role_bindings (user_id, role_id, scope_type, scope_id, assigned_by)
VALUES ($1, $2, 'system', 0, $1)
ON CONFLICT DO NOTHING
`, id, roleID)
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return s.GetUserByID(ctx, id)
}
func (s *PgStore) UpdateUserStatus(ctx context.Context, id int64, status AccountStatus) (*UserAccount, error) {
_, err := s.pool.Exec(ctx, `
UPDATE users SET status = $2, updated_at = now() WHERE id = $1
`, id, status)
if err != nil {
return nil, err
}
if status == StatusDisabled {
_ = s.RevokeAllForUser(ctx, id)
}
return s.GetUserByID(ctx, id)
}
func (s *PgStore) UpdateUserPassword(ctx context.Context, id int64, passwordHash string, mustChangePassword bool) (*UserAccount, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return nil, err
}
defer tx.Rollback(ctx)
_, err = tx.Exec(ctx, `
INSERT INTO user_credentials (user_id, password_hash, password_changed_at, failed_count, locked_until)
VALUES ($1, $2, now(), 0, NULL)
ON CONFLICT (user_id) DO UPDATE SET
password_hash = EXCLUDED.password_hash,
password_changed_at = now(),
failed_count = 0,
locked_until = NULL
`, id, passwordHash)
if err != nil {
return nil, err
}
_, err = tx.Exec(ctx, `
UPDATE users SET must_change_password = $2, updated_at = now() WHERE id = $1
`, id, mustChangePassword)
if err != nil {
return nil, err
}
if err := tx.Commit(ctx); err != nil {
return nil, err
}
return s.GetUserByID(ctx, id)
}
func (s *PgStore) ListUsers(ctx context.Context) ([]*UserAccount, error) {
rows, err := s.pool.Query(ctx, userSelectSQL+` ORDER BY u.id ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
list := make([]*UserAccount, 0)
for rows.Next() {
u, err := s.scanUser(rows)
if err != nil {
return nil, err
}
u.PasswordHash = ""
list = append(list, u)
}
return list, rows.Err()
}
func (s *PgStore) GetConfig(ctx context.Context) (SystemAuthConfig, error) {
var raw []byte
err := s.pool.QueryRow(ctx, `
SELECT config_value FROM iam_system_config WHERE config_key = 'auth'
`).Scan(&raw)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return SystemAuthConfig{AllowRegistration: true, RequireApproval: false}, nil
}
return SystemAuthConfig{}, err
}
var cfg SystemAuthConfig
if err := json.Unmarshal(raw, &cfg); err != nil {
return SystemAuthConfig{}, err
}
return cfg, nil
}
func (s *PgStore) UpdateConfig(ctx context.Context, cfg SystemAuthConfig) error {
raw, err := json.Marshal(cfg)
if err != nil {
return err
}
_, err = s.pool.Exec(ctx, `
INSERT INTO iam_system_config (config_key, config_value, updated_at)
VALUES ('auth', $1, now())
ON CONFLICT (config_key) DO UPDATE SET config_value = EXCLUDED.config_value, updated_at = now()
`, raw)
return err
}
func (s *PgStore) CreateSession(ctx context.Context, userID int64, tokenHash string, meta SessionMeta) error {
now := time.Now()
_, err := s.pool.Exec(ctx, `
INSERT INTO auth_sessions (
token_hash, user_id, created_at, last_seen_at, idle_expires_at, absolute_expires_at, ip_hash, user_agent
) VALUES ($1, $2, $3, $3, $4, $5, $6, $7)
`, tokenHash, userID, now, now.Add(defaultIdleSessionTTL), now.Add(defaultAbsoluteSessionTTL), meta.IPHash, meta.UserAgent)
return err
}
func (s *PgStore) ResolveUserID(ctx context.Context, tokenHash string) (int64, error) {
var userID int64
var idleExp, absExp time.Time
var revokedAt *time.Time
err := s.pool.QueryRow(ctx, `
SELECT user_id, idle_expires_at, absolute_expires_at, revoked_at
FROM auth_sessions WHERE token_hash = $1
`, tokenHash).Scan(&userID, &idleExp, &absExp, &revokedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return 0, errSessionNotFound
}
return 0, err
}
if revokedAt != nil {
return 0, errSessionNotFound
}
now := time.Now()
if now.After(idleExp) || now.After(absExp) {
return 0, errSessionNotFound
}
var status AccountStatus
if err := s.pool.QueryRow(ctx, `SELECT status FROM users WHERE id = $1`, userID).Scan(&status); err != nil {
return 0, errSessionNotFound
}
if status == StatusDisabled || status == StatusPendingApproval {
return 0, errSessionNotFound
}
_, _ = s.pool.Exec(ctx, `
UPDATE auth_sessions SET last_seen_at = now(), idle_expires_at = $2 WHERE token_hash = $1
`, tokenHash, now.Add(defaultIdleSessionTTL))
return userID, nil
}
func (s *PgStore) TouchSession(ctx context.Context, tokenHash string) error {
_, err := s.pool.Exec(ctx, `
UPDATE auth_sessions SET last_seen_at = now(), idle_expires_at = $2
WHERE token_hash = $1 AND revoked_at IS NULL
`, tokenHash, time.Now().Add(defaultIdleSessionTTL))
return err
}
func (s *PgStore) RevokeByTokenHash(ctx context.Context, tokenHash string) error {
_, err := s.pool.Exec(ctx, `
UPDATE auth_sessions SET revoked_at = now() WHERE token_hash = $1 AND revoked_at IS NULL
`, tokenHash)
return err
}
func (s *PgStore) RevokeAllForUser(ctx context.Context, userID int64) error {
_, err := s.pool.Exec(ctx, `
UPDATE auth_sessions SET revoked_at = now() WHERE user_id = $1 AND revoked_at IS NULL
`, userID)
return err
}
func (s *PgStore) ListActiveSessions(ctx context.Context, userID int64, currentTokenHash string) ([]SessionRecord, error) {
var currentID string
if currentTokenHash != "" {
_ = s.pool.QueryRow(ctx, `
SELECT id::text FROM auth_sessions
WHERE token_hash = $1 AND user_id = $2 AND revoked_at IS NULL
`, currentTokenHash, userID).Scan(&currentID)
}
rows, err := s.pool.Query(ctx, `
SELECT id::text, created_at, last_seen_at, user_agent
FROM auth_sessions
WHERE user_id = $1
AND revoked_at IS NULL
AND idle_expires_at > now()
AND absolute_expires_at > now()
ORDER BY last_seen_at DESC
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]SessionRecord, 0)
for rows.Next() {
var rec SessionRecord
var createdAt, lastSeenAt time.Time
if err := rows.Scan(&rec.ID, &createdAt, &lastSeenAt, &rec.UserAgent); err != nil {
return nil, err
}
rec.CreatedAt = createdAt.Format("2006-01-02 15:04:05")
rec.LastSeenAt = lastSeenAt.Format("2006-01-02 15:04:05")
rec.IsCurrent = currentID != "" && rec.ID == currentID
out = append(out, rec)
}
return out, rows.Err()
}
func (s *PgStore) RevokeSessionByID(ctx context.Context, userID int64, sessionID string) error {
tag, err := s.pool.Exec(ctx, `
UPDATE auth_sessions SET revoked_at = now()
WHERE id = $1::uuid AND user_id = $2 AND revoked_at IS NULL
`, sessionID, userID)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return errSessionNotFound
}
return nil
}