summaryrefslogtreecommitdiff
path: root/internal/controlplane
diff options
context:
space:
mode:
Diffstat (limited to 'internal/controlplane')
-rw-r--r--internal/controlplane/access.go249
-rw-r--r--internal/controlplane/access_test.go2
-rw-r--r--internal/controlplane/audit.go6
-rw-r--r--internal/controlplane/manager.go4
-rw-r--r--internal/controlplane/manager_test.go37
-rw-r--r--internal/controlplane/schema.sql44
-rw-r--r--internal/controlplane/types.go30
7 files changed, 347 insertions, 25 deletions
diff --git a/internal/controlplane/access.go b/internal/controlplane/access.go
index ec2d177..0792e8b 100644
--- a/internal/controlplane/access.go
+++ b/internal/controlplane/access.go
@@ -9,11 +9,17 @@ import (
"fmt"
"net/mail"
"strings"
+ "time"
+
+ "aigw/internal/security"
"github.com/jackc/pgx/v5"
)
-var ErrConsoleUnauthorized = errors.New("invalid console token")
+var (
+ ErrConsoleUnauthorized = errors.New("invalid console credentials")
+ ErrConsoleRateLimited = errors.New("too many login attempts")
+)
const (
RolePlatformAdmin = "platform_admin"
@@ -37,7 +43,7 @@ func (a ConsoleActor) Can(permission string) bool {
case RoleTenantAdmin:
switch permission {
case "overview.read", "tenants.read", "projects.read", "projects.write", "keys.read", "keys.write",
- "billing.read", "billing.topup", "usage.read", "audit.read", "limits.read", "users.read", "users.write":
+ "billing.read", "billing.topup", "usage.read", "audit.read", "limits.read", "limits.write", "users.read", "users.write":
return true
}
return false
@@ -81,7 +87,8 @@ func (s *Store) AuthenticateConsoleToken(ctx context.Context, raw string) (Conso
}
func (s *Store) ListConsoleUsers(ctx context.Context, tenantID string) ([]ConsoleUser, error) {
- query := `SELECT id::text, COALESCE(tenant_id::text, ''), email, display_name, role, token_prefix, status, last_used_at, created_at FROM console_users`
+ query := `SELECT id::text, COALESCE(tenant_id::text, ''), email, display_name, role,
+ COALESCE(token_prefix, ''), password_hash IS NOT NULL, status, last_used_at, created_at FROM console_users`
args := []any{}
if tenantID != "" {
query += ` WHERE tenant_id = $1`
@@ -96,7 +103,7 @@ func (s *Store) ListConsoleUsers(ctx context.Context, tenantID string) ([]Consol
result := make([]ConsoleUser, 0)
for rows.Next() {
var item ConsoleUser
- if err := rows.Scan(&item.ID, &item.TenantID, &item.Email, &item.DisplayName, &item.Role, &item.TokenPrefix, &item.Status, &item.LastUsedAt, &item.CreatedAt); err != nil {
+ if err := rows.Scan(&item.ID, &item.TenantID, &item.Email, &item.DisplayName, &item.Role, &item.TokenPrefix, &item.HasPassword, &item.Status, &item.LastUsedAt, &item.CreatedAt); err != nil {
return nil, fmt.Errorf("scan console user: %w", err)
}
result = append(result, item)
@@ -117,40 +124,246 @@ func (s *Store) CreateConsoleUser(ctx context.Context, input CreateConsoleUserIn
if (!platform && !tenant) || (platform && input.TenantID != "") || (tenant && input.TenantID == "") {
return CreatedConsoleUser{}, errors.New("console user role and tenant_id are inconsistent")
}
- random := make([]byte, 32)
- if _, err := rand.Read(random); err != nil {
- return CreatedConsoleUser{}, fmt.Errorf("generate console token: %w", err)
+ hash, salt, iterations, err := security.HashPassword(input.Password)
+ if err != nil {
+ return CreatedConsoleUser{}, err
}
- raw := "cu-aigw-" + base64.RawURLEncoding.EncodeToString(random)
- hash := sha256.Sum256([]byte(raw))
- prefix := raw[:min(18, len(raw))] + "..."
var result CreatedConsoleUser
- err := s.db.QueryRow(ctx, `
- INSERT INTO console_users (tenant_id, email, display_name, role, token_prefix, token_hash)
- VALUES (NULLIF($1,'')::uuid,$2,$3,$4,$5,$6)
- RETURNING id::text, COALESCE(tenant_id::text, ''), email, display_name, role, token_prefix, status, last_used_at, created_at`,
- input.TenantID, input.Email, input.DisplayName, input.Role, prefix, hash[:],
- ).Scan(&result.ID, &result.TenantID, &result.Email, &result.DisplayName, &result.Role, &result.TokenPrefix, &result.Status, &result.LastUsedAt, &result.CreatedAt)
+ err = s.db.QueryRow(ctx, `
+ INSERT INTO console_users (tenant_id, email, display_name, role, password_hash, password_salt, password_iterations, password_changed_at)
+ VALUES (NULLIF($1,'')::uuid,$2,$3,$4,$5,$6,$7,now())
+ RETURNING id::text, COALESCE(tenant_id::text, ''), email, display_name, role, COALESCE(token_prefix,''),
+ password_hash IS NOT NULL, status, last_used_at, created_at`,
+ input.TenantID, input.Email, input.DisplayName, input.Role, hash, salt, iterations,
+ ).Scan(&result.ID, &result.TenantID, &result.Email, &result.DisplayName, &result.Role, &result.TokenPrefix,
+ &result.HasPassword, &result.Status, &result.LastUsedAt, &result.CreatedAt)
if err != nil {
return CreatedConsoleUser{}, fmt.Errorf("create console user: %w", err)
}
- result.Token = raw
return result, nil
}
func (s *Store) RevokeConsoleUser(ctx context.Context, id, tenantID string) error {
+ tx, err := s.db.Begin(ctx)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback(ctx)
query := `UPDATE console_users SET status='revoked', revoked_at=now() WHERE id=$1 AND status='active'`
args := []any{id}
if tenantID != "" {
query += ` AND tenant_id=$2`
args = append(args, tenantID)
}
- result, err := s.db.Exec(ctx, query, args...)
+ result, err := tx.Exec(ctx, query, args...)
if err != nil {
return err
}
if result.RowsAffected() == 0 {
return ErrNotFound
}
+ if _, err := tx.Exec(ctx, `UPDATE console_sessions SET revoked_at=now() WHERE user_id=$1 AND revoked_at IS NULL`, id); err != nil {
+ return fmt.Errorf("revoke console user sessions: %w", err)
+ }
+ return tx.Commit(ctx)
+}
+
+func (s *Store) RegisterTenant(ctx context.Context, input RegisterInput, currency string) (ConsoleActor, int64, error) {
+ input.Organization = strings.TrimSpace(input.Organization)
+ input.TenantSlug = strings.ToLower(strings.TrimSpace(input.TenantSlug))
+ input.DisplayName = strings.TrimSpace(input.DisplayName)
+ input.Email = strings.ToLower(strings.TrimSpace(input.Email))
+ if input.Organization == "" || input.DisplayName == "" || !slugPattern.MatchString(input.TenantSlug) || !validEmail(input.Email) {
+ return ConsoleActor{}, 0, errors.New("registration requires organization, a valid tenant_slug, display_name, and email")
+ }
+ if len(currency) != 3 {
+ return ConsoleActor{}, 0, errors.New("registration currency is invalid")
+ }
+ hash, salt, iterations, err := security.HashPassword(input.Password)
+ if err != nil {
+ return ConsoleActor{}, 0, err
+ }
+ tx, err := s.db.Begin(ctx)
+ if err != nil {
+ return ConsoleActor{}, 0, err
+ }
+ defer tx.Rollback(ctx)
+ var tenantID string
+ if err := tx.QueryRow(ctx, `INSERT INTO tenants (slug,name) VALUES ($1,$2) RETURNING id::text`, input.TenantSlug, input.Organization).Scan(&tenantID); err != nil {
+ return ConsoleActor{}, 0, fmt.Errorf("create registered tenant: %w", err)
+ }
+ if _, err := tx.Exec(ctx, `INSERT INTO projects (tenant_id,slug,name) VALUES ($1,'default','Default project')`, tenantID); err != nil {
+ return ConsoleActor{}, 0, fmt.Errorf("create default project: %w", err)
+ }
+ if _, err := tx.Exec(ctx, `INSERT INTO tenant_wallets (tenant_id,currency) VALUES ($1,$2)`, tenantID, strings.ToLower(currency)); err != nil {
+ return ConsoleActor{}, 0, fmt.Errorf("create tenant wallet: %w", err)
+ }
+ actor := ConsoleActor{TenantID: tenantID, Email: input.Email, DisplayName: input.DisplayName, Role: RoleTenantAdmin}
+ if err := tx.QueryRow(ctx, `INSERT INTO console_users
+ (tenant_id,email,display_name,role,password_hash,password_salt,password_iterations,password_changed_at)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,now()) RETURNING id::text`, tenantID, input.Email, input.DisplayName,
+ RoleTenantAdmin, hash, salt, iterations).Scan(&actor.ID); err != nil {
+ return ConsoleActor{}, 0, fmt.Errorf("create tenant administrator: %w", err)
+ }
+ generation, err := bumpGeneration(ctx, tx)
+ if err != nil {
+ return ConsoleActor{}, 0, err
+ }
+ if err := tx.Commit(ctx); err != nil {
+ return ConsoleActor{}, 0, err
+ }
+ return actor, generation, nil
+}
+
+func (s *Store) AuthenticateConsolePassword(ctx context.Context, input PasswordLoginInput, remoteIP string) (ConsoleActor, error) {
+ email := strings.ToLower(strings.TrimSpace(input.Email))
+ identity := sha256.Sum256([]byte(email + "\x00" + remoteIP))
+ var lockedUntil *time.Time
+ err := s.db.QueryRow(ctx, `SELECT locked_until FROM console_login_throttles WHERE identity_hash=$1`, identity[:]).Scan(&lockedUntil)
+ if err != nil && !errors.Is(err, pgx.ErrNoRows) {
+ return ConsoleActor{}, fmt.Errorf("read console login throttle: %w", err)
+ }
+ if lockedUntil != nil && lockedUntil.After(time.Now()) {
+ return ConsoleActor{}, ErrConsoleRateLimited
+ }
+
+ var actor ConsoleActor
+ var expectedHash, salt []byte
+ var iterations int
+ err = s.db.QueryRow(ctx, `SELECT u.id::text, COALESCE(u.tenant_id::text,''), u.email, u.display_name, u.role,
+ u.password_hash, u.password_salt, u.password_iterations
+ FROM console_users u LEFT JOIN tenants t ON t.id=u.tenant_id
+ WHERE lower(u.email)=$1 AND u.status='active' AND u.password_hash IS NOT NULL
+ AND (u.tenant_id IS NULL OR t.status='active')`, email,
+ ).Scan(&actor.ID, &actor.TenantID, &actor.Email, &actor.DisplayName, &actor.Role, &expectedHash, &salt, &iterations)
+ if errors.Is(err, pgx.ErrNoRows) {
+ dummyHash := make([]byte, security.PasswordHashBytes)
+ dummySalt := make([]byte, security.PasswordSaltBytes)
+ _ = security.VerifyPassword(input.Password, dummyHash, dummySalt, security.PasswordIterations)
+ if failureErr := s.recordLoginFailure(ctx, identity[:]); failureErr != nil {
+ return ConsoleActor{}, failureErr
+ }
+ return ConsoleActor{}, ErrConsoleUnauthorized
+ }
+ if err != nil {
+ return ConsoleActor{}, fmt.Errorf("query console login: %w", err)
+ }
+ if !security.VerifyPassword(input.Password, expectedHash, salt, iterations) {
+ if failureErr := s.recordLoginFailure(ctx, identity[:]); failureErr != nil {
+ return ConsoleActor{}, failureErr
+ }
+ return ConsoleActor{}, ErrConsoleUnauthorized
+ }
+ if _, err := s.db.Exec(ctx, `DELETE FROM console_login_throttles WHERE identity_hash=$1`, identity[:]); err != nil {
+ return ConsoleActor{}, fmt.Errorf("clear console login throttle: %w", err)
+ }
+ if _, err := s.db.Exec(ctx, `UPDATE console_users SET last_used_at=now() WHERE id=$1`, actor.ID); err != nil {
+ return ConsoleActor{}, fmt.Errorf("update console login time: %w", err)
+ }
+ return actor, nil
+}
+
+func (s *Store) recordLoginFailure(ctx context.Context, identityHash []byte) error {
+ _, err := s.db.Exec(ctx, `INSERT INTO console_login_throttles (identity_hash,failures) VALUES ($1,1)
+ ON CONFLICT (identity_hash) DO UPDATE SET
+ failures=CASE WHEN console_login_throttles.window_started_at < now()-interval '15 minutes' THEN 1 ELSE console_login_throttles.failures+1 END,
+ window_started_at=CASE WHEN console_login_throttles.window_started_at < now()-interval '15 minutes' THEN now() ELSE console_login_throttles.window_started_at END,
+ locked_until=CASE WHEN (CASE WHEN console_login_throttles.window_started_at < now()-interval '15 minutes' THEN 1 ELSE console_login_throttles.failures+1 END) >= 5 THEN now()+interval '15 minutes' ELSE NULL END,
+ updated_at=now()`, identityHash)
+ if err != nil {
+ return fmt.Errorf("record console login failure: %w", err)
+ }
return nil
}
+
+func (s *Store) CreateConsoleSession(ctx context.Context, actor ConsoleActor, ttl time.Duration, remoteIP, userAgent string) (ConsoleSession, error) {
+ if actor.ID == "" || ttl < time.Minute {
+ return ConsoleSession{}, errors.New("session user and ttl are required")
+ }
+ token, tokenHash, err := randomCredential("sess-aigw-")
+ if err != nil {
+ return ConsoleSession{}, err
+ }
+ csrf, csrfHash, err := randomCredential("")
+ if err != nil {
+ return ConsoleSession{}, err
+ }
+ expiresAt := time.Now().UTC().Add(ttl)
+ _, err = s.db.Exec(ctx, `INSERT INTO console_sessions (user_id,token_hash,csrf_hash,expires_at,remote_ip,user_agent)
+ VALUES ($1,$2,$3,$4,NULLIF($5,'')::inet,$6)`, actor.ID, tokenHash, csrfHash, expiresAt, remoteIP, userAgent)
+ if err != nil {
+ return ConsoleSession{}, fmt.Errorf("create console session: %w", err)
+ }
+ return ConsoleSession{Actor: actor, Token: token, CSRFToken: csrf, ExpiresAt: expiresAt}, nil
+}
+
+func (s *Store) AuthenticateConsoleSession(ctx context.Context, rawToken string) (ConsoleActor, []byte, error) {
+ hash := sha256.Sum256([]byte(rawToken))
+ var actor ConsoleActor
+ var csrfHash []byte
+ err := s.db.QueryRow(ctx, `UPDATE console_sessions s SET last_seen_at=CASE WHEN s.last_seen_at < now()-interval '5 minutes' THEN now() ELSE s.last_seen_at END
+ FROM console_users u LEFT JOIN tenants t ON t.id=u.tenant_id
+ WHERE s.user_id=u.id AND s.token_hash=$1 AND s.revoked_at IS NULL AND s.expires_at>now()
+ AND u.status='active' AND (u.tenant_id IS NULL OR t.status='active')
+ RETURNING u.id::text,COALESCE(u.tenant_id::text,''),u.email,u.display_name,u.role,s.csrf_hash`, hash[:],
+ ).Scan(&actor.ID, &actor.TenantID, &actor.Email, &actor.DisplayName, &actor.Role, &csrfHash)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return ConsoleActor{}, nil, ErrConsoleUnauthorized
+ }
+ if err != nil {
+ return ConsoleActor{}, nil, fmt.Errorf("authenticate console session: %w", err)
+ }
+ return actor, csrfHash, nil
+}
+
+func (s *Store) RevokeConsoleSession(ctx context.Context, rawToken string) error {
+ if rawToken == "" {
+ return nil
+ }
+ hash := sha256.Sum256([]byte(rawToken))
+ _, err := s.db.Exec(ctx, `UPDATE console_sessions SET revoked_at=now() WHERE token_hash=$1 AND revoked_at IS NULL`, hash[:])
+ return err
+}
+
+func (s *Store) ChangeConsolePassword(ctx context.Context, actorID string, input PasswordChangeInput) error {
+ var expectedHash, salt []byte
+ var iterations int
+ if err := s.db.QueryRow(ctx, `SELECT password_hash,password_salt,password_iterations FROM console_users WHERE id=$1 AND status='active'`, actorID).
+ Scan(&expectedHash, &salt, &iterations); err != nil {
+ return ErrConsoleUnauthorized
+ }
+ if !security.VerifyPassword(input.CurrentPassword, expectedHash, salt, iterations) {
+ return ErrConsoleUnauthorized
+ }
+ hash, newSalt, newIterations, err := security.HashPassword(input.NewPassword)
+ if err != nil {
+ return err
+ }
+ tx, err := s.db.Begin(ctx)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback(ctx)
+ if _, err := tx.Exec(ctx, `UPDATE console_users SET password_hash=$2,password_salt=$3,password_iterations=$4,password_changed_at=now() WHERE id=$1`, actorID, hash, newSalt, newIterations); err != nil {
+ return err
+ }
+ if _, err := tx.Exec(ctx, `UPDATE console_sessions SET revoked_at=now() WHERE user_id=$1 AND revoked_at IS NULL`, actorID); err != nil {
+ return err
+ }
+ return tx.Commit(ctx)
+}
+
+func randomCredential(prefix string) (string, []byte, error) {
+ random := make([]byte, 32)
+ if _, err := rand.Read(random); err != nil {
+ return "", nil, err
+ }
+ raw := prefix + base64.RawURLEncoding.EncodeToString(random)
+ hash := sha256.Sum256([]byte(raw))
+ return raw, hash[:], nil
+}
+
+func validEmail(value string) bool {
+ address, err := mail.ParseAddress(value)
+ return err == nil && address.Address == value
+}
diff --git a/internal/controlplane/access_test.go b/internal/controlplane/access_test.go
index 96e3869..7767e0d 100644
--- a/internal/controlplane/access_test.go
+++ b/internal/controlplane/access_test.go
@@ -11,7 +11,7 @@ func TestConsoleRolePermissions(t *testing.T) {
{RolePlatformViewer, "platform.read", true},
{RolePlatformViewer, "platform.write", false},
{RoleTenantAdmin, "keys.write", true},
- {RoleTenantAdmin, "limits.write", false},
+ {RoleTenantAdmin, "limits.write", true},
{RoleTenantBilling, "billing.topup", true},
{RoleTenantBilling, "keys.read", false},
{RoleTenantDeveloper, "keys.write", true},
diff --git a/internal/controlplane/audit.go b/internal/controlplane/audit.go
index 93a4f58..8cb4ffe 100644
--- a/internal/controlplane/audit.go
+++ b/internal/controlplane/audit.go
@@ -8,13 +8,17 @@ import (
func (s *Store) WriteAudit(ctx context.Context, input AuditInput) error {
actorType := "console_user"
+ actorRole := input.Actor.Role
if input.Actor.Bootstrap {
actorType = "bootstrap"
+ } else if input.Actor.ID == "" {
+ actorType = "anonymous"
+ actorRole = "anonymous"
}
_, err := s.db.Exec(ctx, `INSERT INTO audit_logs
(actor_id, actor_type, actor_role, tenant_id, request_id, method, path, action, status_code, remote_ip, user_agent)
VALUES (NULLIF($1,'')::uuid,$2,$3,NULLIF($4,'')::uuid,$5,$6,$7,$8,$9,NULLIF($10,'')::inet,$11)`,
- input.Actor.ID, actorType, input.Actor.Role, input.Actor.TenantID, input.RequestID, input.Method,
+ input.Actor.ID, actorType, actorRole, input.Actor.TenantID, input.RequestID, input.Method,
input.Path, input.Action, input.StatusCode, input.RemoteIP, input.UserAgent)
if err != nil {
return fmt.Errorf("write audit log: %w", err)
diff --git a/internal/controlplane/manager.go b/internal/controlplane/manager.go
index cdafc36..b6be748 100644
--- a/internal/controlplane/manager.go
+++ b/internal/controlplane/manager.go
@@ -176,6 +176,10 @@ func (m *Manager) runSubscriptions(ctx context.Context) {
closed = true
continue
}
+ // go-redis transparently reconnects Pub/Sub after a network outage.
+ // Receiving a message is the strongest signal that this subscription
+ // is live again, including when a concurrent publish previously failed.
+ m.redisConnected.Store(true)
var event ChangeEvent
if json.Unmarshal([]byte(message.Payload), &event) != nil || event.Generation <= m.generation.Load() {
continue
diff --git a/internal/controlplane/manager_test.go b/internal/controlplane/manager_test.go
index ee78a00..b292060 100644
--- a/internal/controlplane/manager_test.go
+++ b/internal/controlplane/manager_test.go
@@ -192,6 +192,43 @@ func TestSubscriptionReconnectsAfterChannelCloses(t *testing.T) {
}
}
+func TestSubscriptionMessageRestoresConnectedStateAfterPublishFailure(t *testing.T) {
+ store := newFakeManagerStore(1)
+ store.redisEnabled = true
+ store.publishErr = errors.New("redis unavailable")
+ messages := make(chan ChangeMessage, 1)
+ store.subscribe = func(_ context.Context, _ int64) (<-chan ChangeMessage, func() error, error) {
+ return messages, func() error { return nil }, nil
+ }
+ manager := newTestManager(store, slog.New(slog.NewTextHandler(&safeLogBuffer{}, nil)), 10*time.Millisecond)
+ if _, err := manager.Reload(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+
+ ctx, cancel := context.WithCancel(context.Background())
+ done := make(chan struct{})
+ go func() {
+ manager.Run(ctx)
+ close(done)
+ }()
+ waitUntil(t, time.Second, manager.RedisConnected)
+ if err := manager.AfterMutation(context.Background(), 1, "model", "model-1"); err != nil {
+ t.Fatal(err)
+ }
+ waitUntil(t, time.Second, func() bool { return !manager.RedisConnected() })
+
+ store.snapshot.Store(Snapshot{Generation: 2})
+ messages <- ChangeMessage{Payload: `{"generation":2,"resource":"model"}`}
+ waitUntil(t, time.Second, func() bool { return manager.RedisConnected() && manager.Generation() == 2 })
+
+ cancel()
+ select {
+ case <-done:
+ case <-time.After(time.Second):
+ t.Fatal("manager did not stop")
+ }
+}
+
func TestRedisCanBeDisabled(t *testing.T) {
store := newFakeManagerStore(3)
manager := newTestManager(store, slog.New(slog.NewTextHandler(&bytes.Buffer{}, nil)), 10*time.Millisecond)
diff --git a/internal/controlplane/schema.sql b/internal/controlplane/schema.sql
index a518b1d..fc7f5e7 100644
--- a/internal/controlplane/schema.sql
+++ b/internal/controlplane/schema.sql
@@ -195,16 +195,50 @@ CREATE TABLE IF NOT EXISTS console_users (
'platform_admin', 'platform_viewer', 'tenant_admin',
'tenant_billing', 'tenant_developer', 'tenant_viewer'
)),
- token_prefix TEXT NOT NULL,
- token_hash BYTEA NOT NULL UNIQUE CHECK (octet_length(token_hash) = 32),
+ token_prefix TEXT NOT NULL DEFAULT '',
+ token_hash BYTEA UNIQUE CHECK (token_hash IS NULL OR octet_length(token_hash) = 32),
+ password_hash BYTEA CHECK (password_hash IS NULL OR octet_length(password_hash) = 32),
+ password_salt BYTEA CHECK (password_salt IS NULL OR octet_length(password_salt) = 16),
+ password_iterations INTEGER CHECK (password_iterations IS NULL OR password_iterations >= 100000),
+ password_changed_at TIMESTAMPTZ,
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'revoked')),
last_used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
revoked_at TIMESTAMPTZ,
CHECK ((role LIKE 'platform_%' AND tenant_id IS NULL) OR (role LIKE 'tenant_%' AND tenant_id IS NOT NULL))
);
+ALTER TABLE console_users ALTER COLUMN token_prefix SET DEFAULT '';
+ALTER TABLE console_users ALTER COLUMN token_prefix DROP NOT NULL;
+ALTER TABLE console_users ALTER COLUMN token_hash DROP NOT NULL;
+ALTER TABLE console_users ADD COLUMN IF NOT EXISTS password_hash BYTEA;
+ALTER TABLE console_users ADD COLUMN IF NOT EXISTS password_salt BYTEA;
+ALTER TABLE console_users ADD COLUMN IF NOT EXISTS password_iterations INTEGER;
+ALTER TABLE console_users ADD COLUMN IF NOT EXISTS password_changed_at TIMESTAMPTZ;
CREATE UNIQUE INDEX IF NOT EXISTS console_users_email_tenant_idx
ON console_users (lower(email), COALESCE(tenant_id, '00000000-0000-0000-0000-000000000000'::uuid));
+CREATE UNIQUE INDEX IF NOT EXISTS console_users_login_email_idx
+ ON console_users (lower(email)) WHERE password_hash IS NOT NULL;
+
+CREATE TABLE IF NOT EXISTS console_sessions (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID NOT NULL REFERENCES console_users(id) ON DELETE CASCADE,
+ token_hash BYTEA NOT NULL UNIQUE CHECK (octet_length(token_hash) = 32),
+ csrf_hash BYTEA NOT NULL CHECK (octet_length(csrf_hash) = 32),
+ expires_at TIMESTAMPTZ NOT NULL,
+ last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ revoked_at TIMESTAMPTZ,
+ remote_ip INET,
+ user_agent TEXT NOT NULL DEFAULT ''
+);
+
+CREATE TABLE IF NOT EXISTS console_login_throttles (
+ identity_hash BYTEA PRIMARY KEY CHECK (octet_length(identity_hash) = 32),
+ failures INTEGER NOT NULL DEFAULT 0,
+ window_started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ locked_until TIMESTAMPTZ,
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
CREATE TABLE IF NOT EXISTS project_limits (
project_id UUID PRIMARY KEY,
@@ -237,7 +271,7 @@ CREATE TABLE IF NOT EXISTS usage_monthly_rollups (
CREATE TABLE IF NOT EXISTS audit_logs (
id BIGSERIAL PRIMARY KEY,
actor_id UUID REFERENCES console_users(id) ON DELETE SET NULL,
- actor_type TEXT NOT NULL CHECK (actor_type IN ('bootstrap', 'console_user')),
+ actor_type TEXT NOT NULL CHECK (actor_type IN ('bootstrap', 'console_user', 'anonymous')),
actor_role TEXT NOT NULL,
tenant_id UUID REFERENCES tenants(id) ON DELETE SET NULL,
request_id TEXT NOT NULL,
@@ -249,6 +283,8 @@ CREATE TABLE IF NOT EXISTS audit_logs (
user_agent TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
+ALTER TABLE audit_logs DROP CONSTRAINT IF EXISTS audit_logs_actor_type_check;
+ALTER TABLE audit_logs ADD CONSTRAINT audit_logs_actor_type_check CHECK (actor_type IN ('bootstrap', 'console_user', 'anonymous'));
CREATE INDEX IF NOT EXISTS billing_ledger_tenant_idx ON billing_ledger (tenant_id, created_at DESC);
CREATE INDEX IF NOT EXISTS usage_events_tenant_idx ON usage_events (tenant_id, created_at DESC);
@@ -257,5 +293,7 @@ CREATE INDEX IF NOT EXISTS usage_events_model_idx ON usage_events (public_model,
CREATE INDEX IF NOT EXISTS billing_reservations_pending_idx ON billing_reservations (status, created_at) WHERE status = 'pending';
CREATE INDEX IF NOT EXISTS billing_reservations_project_pending_idx ON billing_reservations (project_id, created_at) WHERE status = 'pending';
CREATE INDEX IF NOT EXISTS console_users_tenant_idx ON console_users (tenant_id, created_at DESC);
+CREATE INDEX IF NOT EXISTS console_sessions_user_idx ON console_sessions (user_id, created_at DESC);
+CREATE INDEX IF NOT EXISTS console_sessions_expiry_idx ON console_sessions (expires_at) WHERE revoked_at IS NULL;
CREATE INDEX IF NOT EXISTS audit_logs_created_idx ON audit_logs (created_at DESC);
CREATE INDEX IF NOT EXISTS audit_logs_tenant_idx ON audit_logs (tenant_id, created_at DESC);
diff --git a/internal/controlplane/types.go b/internal/controlplane/types.go
index 7dfb734..959a9ee 100644
--- a/internal/controlplane/types.go
+++ b/internal/controlplane/types.go
@@ -164,7 +164,8 @@ type ConsoleUser struct {
Email string `json:"email"`
DisplayName string `json:"display_name"`
Role string `json:"role"`
- TokenPrefix string `json:"token_prefix"`
+ TokenPrefix string `json:"token_prefix,omitempty"`
+ HasPassword bool `json:"has_password"`
Status string `json:"status"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
@@ -172,7 +173,6 @@ type ConsoleUser struct {
type CreatedConsoleUser struct {
ConsoleUser
- Token string `json:"token"`
}
type CreateConsoleUserInput struct {
@@ -180,6 +180,32 @@ type CreateConsoleUserInput struct {
Email string `json:"email"`
DisplayName string `json:"display_name"`
Role string `json:"role"`
+ Password string `json:"password"`
+}
+
+type RegisterInput struct {
+ Organization string `json:"organization"`
+ TenantSlug string `json:"tenant_slug"`
+ DisplayName string `json:"display_name"`
+ Email string `json:"email"`
+ Password string `json:"password"`
+}
+
+type PasswordLoginInput struct {
+ Email string `json:"email"`
+ Password string `json:"password"`
+}
+
+type PasswordChangeInput struct {
+ CurrentPassword string `json:"current_password"`
+ NewPassword string `json:"new_password"`
+}
+
+type ConsoleSession struct {
+ Actor ConsoleActor
+ Token string
+ CSRFToken string
+ ExpiresAt time.Time
}
type ProjectLimit struct {