From 36b92ff316a287fecbb30bc25586a0ffd4842d26 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Fri, 20 Mar 2026 18:41:32 +0800 Subject: [PATCH 01/53] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20auth=20DB=20sch?= =?UTF-8?q?ema=20tables=20and=20migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 7 new auth tables (auth_users, auth_roles, auth_user_roles, auth_identities, auth_policies, auth_user_agents, auth_sessions) and scope column to settings_agents. Generate Atlas migration. --- .../20260320104110_add-auth-tables.sql | 75 +++++++++++++++++++ internal/db/migrations/atlas.sum | 3 +- internal/db/schemas/main.sql | 7 ++ .../db/schemas/tables/auth_identities.sql | 9 +++ internal/db/schemas/tables/auth_policies.sql | 13 ++++ internal/db/schemas/tables/auth_roles.sql | 7 ++ internal/db/schemas/tables/auth_sessions.sql | 6 ++ .../db/schemas/tables/auth_user_agents.sql | 5 ++ .../db/schemas/tables/auth_user_roles.sql | 5 ++ internal/db/schemas/tables/auth_users.sql | 8 ++ .../db/schemas/tables/settings_agents.sql | 1 + 11 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 internal/db/migrations/20260320104110_add-auth-tables.sql create mode 100644 internal/db/schemas/tables/auth_identities.sql create mode 100644 internal/db/schemas/tables/auth_policies.sql create mode 100644 internal/db/schemas/tables/auth_roles.sql create mode 100644 internal/db/schemas/tables/auth_sessions.sql create mode 100644 internal/db/schemas/tables/auth_user_agents.sql create mode 100644 internal/db/schemas/tables/auth_user_roles.sql create mode 100644 internal/db/schemas/tables/auth_users.sql diff --git a/internal/db/migrations/20260320104110_add-auth-tables.sql b/internal/db/migrations/20260320104110_add-auth-tables.sql new file mode 100644 index 00000000..11637c8f --- /dev/null +++ b/internal/db/migrations/20260320104110_add-auth-tables.sql @@ -0,0 +1,75 @@ +-- Add column "scope" to table: "settings_agents" +ALTER TABLE `settings_agents` ADD COLUMN `scope` text NOT NULL DEFAULT 'system'; +-- Create "auth_users" table +CREATE TABLE `auth_users` ( + `id` integer NULL PRIMARY KEY AUTOINCREMENT, + `username` text NOT NULL, + `password_hash` text NOT NULL, + `is_active` integer NOT NULL DEFAULT 1, + `created_at` text NOT NULL DEFAULT (datetime('now')), + `updated_at` text NOT NULL DEFAULT (datetime('now')) +); +-- Create index "auth_users_username" to table: "auth_users" +CREATE UNIQUE INDEX `auth_users_username` ON `auth_users` (`username`); +-- Create "auth_roles" table +CREATE TABLE `auth_roles` ( + `id` text NULL, + `name` text NOT NULL, + `description` text NOT NULL DEFAULT '', + `is_system` integer NOT NULL DEFAULT 0, + `created_at` text NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (`id`) +); +-- Create "auth_user_roles" table +CREATE TABLE `auth_user_roles` ( + `user_id` integer NOT NULL, + `role_id` text NOT NULL, + PRIMARY KEY (`user_id`, `role_id`), + CONSTRAINT `0` FOREIGN KEY (`role_id`) REFERENCES `auth_roles` (`id`) ON UPDATE NO ACTION ON DELETE CASCADE, + CONSTRAINT `1` FOREIGN KEY (`user_id`) REFERENCES `auth_users` (`id`) ON UPDATE NO ACTION ON DELETE CASCADE +); +-- Create "auth_identities" table +CREATE TABLE `auth_identities` ( + `id` integer NULL PRIMARY KEY AUTOINCREMENT, + `user_id` integer NOT NULL, + `platform` text NOT NULL, + `external_id` text NOT NULL, + `name` text NOT NULL DEFAULT '', + `linked_at` text NOT NULL DEFAULT (datetime('now')), + CONSTRAINT `0` FOREIGN KEY (`user_id`) REFERENCES `auth_users` (`id`) ON UPDATE NO ACTION ON DELETE CASCADE +); +-- Create index "auth_identities_platform_external_id" to table: "auth_identities" +CREATE UNIQUE INDEX `auth_identities_platform_external_id` ON `auth_identities` (`platform`, `external_id`); +-- Create "auth_policies" table +CREATE TABLE `auth_policies` ( + `id` text NULL, + `name` text NOT NULL, + `effect` text NOT NULL, + `subjects` text NOT NULL DEFAULT '{}', + `actions` text NOT NULL DEFAULT '[]', + `resources` text NOT NULL DEFAULT '[]', + `conditions` text NOT NULL DEFAULT '{}', + `priority` integer NOT NULL DEFAULT 0, + `is_system` integer NOT NULL DEFAULT 0, + `enabled` integer NOT NULL DEFAULT 1, + `created_at` text NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (`id`), + CHECK (effect IN ('allow', 'deny')) +); +-- Create "auth_user_agents" table +CREATE TABLE `auth_user_agents` ( + `user_id` integer NOT NULL, + `agent_id` text NOT NULL, + PRIMARY KEY (`user_id`, `agent_id`), + CONSTRAINT `0` FOREIGN KEY (`agent_id`) REFERENCES `settings_agents` (`id`) ON UPDATE NO ACTION ON DELETE CASCADE, + CONSTRAINT `1` FOREIGN KEY (`user_id`) REFERENCES `auth_users` (`id`) ON UPDATE NO ACTION ON DELETE CASCADE +); +-- Create "auth_sessions" table +CREATE TABLE `auth_sessions` ( + `id` text NULL, + `user_id` integer NOT NULL, + `expires_at` text NOT NULL, + `created_at` text NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (`id`), + CONSTRAINT `0` FOREIGN KEY (`user_id`) REFERENCES `auth_users` (`id`) ON UPDATE NO ACTION ON DELETE CASCADE +); diff --git a/internal/db/migrations/atlas.sum b/internal/db/migrations/atlas.sum index a56bb7fe..97d75b42 100644 --- a/internal/db/migrations/atlas.sum +++ b/internal/db/migrations/atlas.sum @@ -1,3 +1,4 @@ -h1:63ztUQEKGL5xTYZxJ8bi6ApOWiRBJK2UAkkHOjk9PI8= +h1:aDSepT3L6cSDktBCflIzFUvQwn+JSs5ZQfs6edC9CbY= 20260317041843_rename_tables_with_prefixes.sql h1:Nfk81sAWddxDQvpo8pBhXJ5Sec5JcCF6tchqgt+777M= 20260317065837_drop_agent_provider_id.sql h1:gb4CoI8GlT4P3QbksGw07M8SruOcR9pQI0Sj7/Q2rao= +20260320104110_add-auth-tables.sql h1:6TjzFFeMMTJzy3/mGN4nrm4+SoYGhanOQY24nUbL3H4= diff --git a/internal/db/schemas/main.sql b/internal/db/schemas/main.sql index ca571458..447c1e69 100644 --- a/internal/db/schemas/main.sql +++ b/internal/db/schemas/main.sql @@ -13,3 +13,10 @@ -- atlas:import tables/ctx_summary_parents.sql -- atlas:import tables/ctx_items.sql -- atlas:import tables/sched_jobs.sql +-- atlas:import tables/auth_users.sql +-- atlas:import tables/auth_roles.sql +-- atlas:import tables/auth_user_roles.sql +-- atlas:import tables/auth_identities.sql +-- atlas:import tables/auth_policies.sql +-- atlas:import tables/auth_user_agents.sql +-- atlas:import tables/auth_sessions.sql diff --git a/internal/db/schemas/tables/auth_identities.sql b/internal/db/schemas/tables/auth_identities.sql new file mode 100644 index 00000000..8adc398d --- /dev/null +++ b/internal/db/schemas/tables/auth_identities.sql @@ -0,0 +1,9 @@ +CREATE TABLE auth_identities ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES auth_users(id) ON DELETE CASCADE, + platform TEXT NOT NULL, + external_id TEXT NOT NULL, + name TEXT NOT NULL DEFAULT '', + linked_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(platform, external_id) +); diff --git a/internal/db/schemas/tables/auth_policies.sql b/internal/db/schemas/tables/auth_policies.sql new file mode 100644 index 00000000..c14eeabe --- /dev/null +++ b/internal/db/schemas/tables/auth_policies.sql @@ -0,0 +1,13 @@ +CREATE TABLE auth_policies ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + effect TEXT NOT NULL CHECK(effect IN ('allow', 'deny')), + subjects TEXT NOT NULL DEFAULT '{}', + actions TEXT NOT NULL DEFAULT '[]', + resources TEXT NOT NULL DEFAULT '[]', + conditions TEXT NOT NULL DEFAULT '{}', + priority INTEGER NOT NULL DEFAULT 0, + is_system INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); diff --git a/internal/db/schemas/tables/auth_roles.sql b/internal/db/schemas/tables/auth_roles.sql new file mode 100644 index 00000000..bc4baeaa --- /dev/null +++ b/internal/db/schemas/tables/auth_roles.sql @@ -0,0 +1,7 @@ +CREATE TABLE auth_roles ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + is_system INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); diff --git a/internal/db/schemas/tables/auth_sessions.sql b/internal/db/schemas/tables/auth_sessions.sql new file mode 100644 index 00000000..5c4c32a9 --- /dev/null +++ b/internal/db/schemas/tables/auth_sessions.sql @@ -0,0 +1,6 @@ +CREATE TABLE auth_sessions ( + id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES auth_users(id) ON DELETE CASCADE, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')) +); diff --git a/internal/db/schemas/tables/auth_user_agents.sql b/internal/db/schemas/tables/auth_user_agents.sql new file mode 100644 index 00000000..66fe02bb --- /dev/null +++ b/internal/db/schemas/tables/auth_user_agents.sql @@ -0,0 +1,5 @@ +CREATE TABLE auth_user_agents ( + user_id INTEGER NOT NULL REFERENCES auth_users(id) ON DELETE CASCADE, + agent_id TEXT NOT NULL REFERENCES settings_agents(id) ON DELETE CASCADE, + PRIMARY KEY(user_id, agent_id) +); diff --git a/internal/db/schemas/tables/auth_user_roles.sql b/internal/db/schemas/tables/auth_user_roles.sql new file mode 100644 index 00000000..f9ae15f3 --- /dev/null +++ b/internal/db/schemas/tables/auth_user_roles.sql @@ -0,0 +1,5 @@ +CREATE TABLE auth_user_roles ( + user_id INTEGER NOT NULL REFERENCES auth_users(id) ON DELETE CASCADE, + role_id TEXT NOT NULL REFERENCES auth_roles(id) ON DELETE CASCADE, + PRIMARY KEY(user_id, role_id) +); diff --git a/internal/db/schemas/tables/auth_users.sql b/internal/db/schemas/tables/auth_users.sql new file mode 100644 index 00000000..eda01c4b --- /dev/null +++ b/internal/db/schemas/tables/auth_users.sql @@ -0,0 +1,8 @@ +CREATE TABLE auth_users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + is_active INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); diff --git a/internal/db/schemas/tables/settings_agents.sql b/internal/db/schemas/tables/settings_agents.sql index ddfed450..cf2d6729 100644 --- a/internal/db/schemas/tables/settings_agents.sql +++ b/internal/db/schemas/tables/settings_agents.sql @@ -6,6 +6,7 @@ CREATE TABLE settings_agents ( model_fast TEXT NOT NULL DEFAULT '', system_prompt TEXT NOT NULL DEFAULT '', workspace TEXT NOT NULL, + scope TEXT NOT NULL DEFAULT 'system', enabled INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')) From 453cc20ecb06a4d3b1ed54509cc3d22fc11526e7 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Fri, 20 Mar 2026 18:42:21 +0800 Subject: [PATCH 02/53] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20sqlc=20queries?= =?UTF-8?q?=20for=20auth=20tables=20and=20regenerate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add query files for auth_users, auth_roles, auth_user_roles, auth_identities, auth_policies, auth_user_agents, auth_sessions. Regenerate sqlc to include new auth types and updated settings_agents. --- internal/db/queries/auth_identities.sql | 16 ++ internal/db/queries/auth_policies.sql | 28 +++ internal/db/queries/auth_roles.sql | 19 +++ internal/db/queries/auth_sessions.sql | 19 +++ internal/db/queries/auth_user_agents.sql | 13 ++ internal/db/queries/auth_user_roles.sql | 19 +++ internal/db/queries/auth_users.sql | 27 +++ internal/db/sqlc/auth_identities.sql.go | 126 ++++++++++++++ internal/db/sqlc/auth_policies.sql.go | 209 +++++++++++++++++++++++ internal/db/sqlc/auth_roles.sql.go | 118 +++++++++++++ internal/db/sqlc/auth_sessions.sql.go | 91 ++++++++++ internal/db/sqlc/auth_user_agents.sql.go | 94 ++++++++++ internal/db/sqlc/auth_user_roles.sql.go | 113 ++++++++++++ internal/db/sqlc/auth_users.sql.go | 151 ++++++++++++++++ internal/db/sqlc/models.go | 58 +++++++ internal/db/sqlc/settings_agents.sql.go | 12 +- 16 files changed, 1109 insertions(+), 4 deletions(-) create mode 100644 internal/db/queries/auth_identities.sql create mode 100644 internal/db/queries/auth_policies.sql create mode 100644 internal/db/queries/auth_roles.sql create mode 100644 internal/db/queries/auth_sessions.sql create mode 100644 internal/db/queries/auth_user_agents.sql create mode 100644 internal/db/queries/auth_user_roles.sql create mode 100644 internal/db/queries/auth_users.sql create mode 100644 internal/db/sqlc/auth_identities.sql.go create mode 100644 internal/db/sqlc/auth_policies.sql.go create mode 100644 internal/db/sqlc/auth_roles.sql.go create mode 100644 internal/db/sqlc/auth_sessions.sql.go create mode 100644 internal/db/sqlc/auth_user_agents.sql.go create mode 100644 internal/db/sqlc/auth_user_roles.sql.go create mode 100644 internal/db/sqlc/auth_users.sql.go diff --git a/internal/db/queries/auth_identities.sql b/internal/db/queries/auth_identities.sql new file mode 100644 index 00000000..5d7f836b --- /dev/null +++ b/internal/db/queries/auth_identities.sql @@ -0,0 +1,16 @@ +-- name: CreateAuthIdentity :one +INSERT INTO auth_identities (user_id, platform, external_id, name) +VALUES (?, ?, ?, ?) +RETURNING *; + +-- name: GetAuthIdentity :one +SELECT * FROM auth_identities WHERE id = ?; + +-- name: GetAuthIdentityByPlatform :one +SELECT * FROM auth_identities WHERE platform = ? AND external_id = ?; + +-- name: ListAuthIdentitiesByUser :many +SELECT * FROM auth_identities WHERE user_id = ? ORDER BY linked_at; + +-- name: DeleteAuthIdentity :exec +DELETE FROM auth_identities WHERE id = ?; diff --git a/internal/db/queries/auth_policies.sql b/internal/db/queries/auth_policies.sql new file mode 100644 index 00000000..58bce833 --- /dev/null +++ b/internal/db/queries/auth_policies.sql @@ -0,0 +1,28 @@ +-- name: CreateAuthPolicy :one +INSERT INTO auth_policies (id, name, effect, subjects, actions, resources, conditions, priority, is_system, enabled) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +RETURNING *; + +-- name: GetAuthPolicy :one +SELECT * FROM auth_policies WHERE id = ?; + +-- name: ListAuthPolicies :many +SELECT * FROM auth_policies ORDER BY priority DESC, name; + +-- name: ListEnabledAuthPolicies :many +SELECT * FROM auth_policies WHERE enabled = 1 ORDER BY priority DESC, name; + +-- name: UpdateAuthPolicy :exec +UPDATE auth_policies SET + name = ?, + effect = ?, + subjects = ?, + actions = ?, + resources = ?, + conditions = ?, + priority = ?, + enabled = ? +WHERE id = ?; + +-- name: DeleteAuthPolicy :exec +DELETE FROM auth_policies WHERE id = ?; diff --git a/internal/db/queries/auth_roles.sql b/internal/db/queries/auth_roles.sql new file mode 100644 index 00000000..f63659e7 --- /dev/null +++ b/internal/db/queries/auth_roles.sql @@ -0,0 +1,19 @@ +-- name: CreateAuthRole :one +INSERT INTO auth_roles (id, name, description, is_system) +VALUES (?, ?, ?, ?) +RETURNING *; + +-- name: GetAuthRole :one +SELECT * FROM auth_roles WHERE id = ?; + +-- name: ListAuthRoles :many +SELECT * FROM auth_roles ORDER BY name; + +-- name: UpdateAuthRole :exec +UPDATE auth_roles SET + name = ?, + description = ? +WHERE id = ?; + +-- name: DeleteAuthRole :exec +DELETE FROM auth_roles WHERE id = ?; diff --git a/internal/db/queries/auth_sessions.sql b/internal/db/queries/auth_sessions.sql new file mode 100644 index 00000000..485f068f --- /dev/null +++ b/internal/db/queries/auth_sessions.sql @@ -0,0 +1,19 @@ +-- name: CreateAuthSession :one +INSERT INTO auth_sessions (id, user_id, expires_at) +VALUES (?, ?, ?) +RETURNING *; + +-- name: GetAuthSession :one +SELECT * FROM auth_sessions WHERE id = ?; + +-- name: DeleteAuthSession :exec +DELETE FROM auth_sessions WHERE id = ?; + +-- name: DeleteExpiredAuthSessions :exec +DELETE FROM auth_sessions WHERE expires_at < datetime('now'); + +-- name: DeleteUserAuthSessions :exec +DELETE FROM auth_sessions WHERE user_id = ?; + +-- name: UpdateAuthSessionExpiry :exec +UPDATE auth_sessions SET expires_at = ? WHERE id = ?; diff --git a/internal/db/queries/auth_user_agents.sql b/internal/db/queries/auth_user_agents.sql new file mode 100644 index 00000000..228611bb --- /dev/null +++ b/internal/db/queries/auth_user_agents.sql @@ -0,0 +1,13 @@ +-- name: AssignUserAgent :exec +INSERT INTO auth_user_agents (user_id, agent_id) +VALUES (?, ?) +ON CONFLICT DO NOTHING; + +-- name: RemoveUserAgent :exec +DELETE FROM auth_user_agents WHERE user_id = ? AND agent_id = ?; + +-- name: ListUserAgents :many +SELECT agent_id FROM auth_user_agents WHERE user_id = ? ORDER BY agent_id; + +-- name: ListAgentUsers :many +SELECT user_id FROM auth_user_agents WHERE agent_id = ? ORDER BY user_id; diff --git a/internal/db/queries/auth_user_roles.sql b/internal/db/queries/auth_user_roles.sql new file mode 100644 index 00000000..89839c57 --- /dev/null +++ b/internal/db/queries/auth_user_roles.sql @@ -0,0 +1,19 @@ +-- name: AssignUserRole :exec +INSERT INTO auth_user_roles (user_id, role_id) +VALUES (?, ?) +ON CONFLICT DO NOTHING; + +-- name: RemoveUserRole :exec +DELETE FROM auth_user_roles WHERE user_id = ? AND role_id = ?; + +-- name: ListUserRoles :many +SELECT r.* FROM auth_roles r +JOIN auth_user_roles ur ON ur.role_id = r.id +WHERE ur.user_id = ? +ORDER BY r.name; + +-- name: ListRoleUsers :many +SELECT u.* FROM auth_users u +JOIN auth_user_roles ur ON ur.user_id = u.id +WHERE ur.role_id = ? +ORDER BY u.username; diff --git a/internal/db/queries/auth_users.sql b/internal/db/queries/auth_users.sql new file mode 100644 index 00000000..10c713e4 --- /dev/null +++ b/internal/db/queries/auth_users.sql @@ -0,0 +1,27 @@ +-- name: CreateAuthUser :one +INSERT INTO auth_users (username, password_hash) +VALUES (?, ?) +RETURNING *; + +-- name: GetAuthUser :one +SELECT * FROM auth_users WHERE id = ?; + +-- name: GetAuthUserByUsername :one +SELECT * FROM auth_users WHERE username = ?; + +-- name: ListAuthUsers :many +SELECT * FROM auth_users ORDER BY username; + +-- name: UpdateAuthUser :exec +UPDATE auth_users SET + username = ?, + password_hash = ?, + is_active = ?, + updated_at = datetime('now') +WHERE id = ?; + +-- name: DeleteAuthUser :exec +DELETE FROM auth_users WHERE id = ?; + +-- name: CountAuthUsers :one +SELECT COUNT(*) FROM auth_users; diff --git a/internal/db/sqlc/auth_identities.sql.go b/internal/db/sqlc/auth_identities.sql.go new file mode 100644 index 00000000..7628b394 --- /dev/null +++ b/internal/db/sqlc/auth_identities.sql.go @@ -0,0 +1,126 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: auth_identities.sql + +package sqlc + +import ( + "context" +) + +const createAuthIdentity = `-- name: CreateAuthIdentity :one +INSERT INTO auth_identities (user_id, platform, external_id, name) +VALUES (?, ?, ?, ?) +RETURNING id, user_id, platform, external_id, name, linked_at +` + +type CreateAuthIdentityParams struct { + UserID int64 `json:"user_id"` + Platform string `json:"platform"` + ExternalID string `json:"external_id"` + Name string `json:"name"` +} + +func (q *Queries) CreateAuthIdentity(ctx context.Context, arg CreateAuthIdentityParams) (AuthIdentity, error) { + row := q.db.QueryRowContext(ctx, createAuthIdentity, + arg.UserID, + arg.Platform, + arg.ExternalID, + arg.Name, + ) + var i AuthIdentity + err := row.Scan( + &i.ID, + &i.UserID, + &i.Platform, + &i.ExternalID, + &i.Name, + &i.LinkedAt, + ) + return i, err +} + +const deleteAuthIdentity = `-- name: DeleteAuthIdentity :exec +DELETE FROM auth_identities WHERE id = ? +` + +func (q *Queries) DeleteAuthIdentity(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, deleteAuthIdentity, id) + return err +} + +const getAuthIdentity = `-- name: GetAuthIdentity :one +SELECT id, user_id, platform, external_id, name, linked_at FROM auth_identities WHERE id = ? +` + +func (q *Queries) GetAuthIdentity(ctx context.Context, id int64) (AuthIdentity, error) { + row := q.db.QueryRowContext(ctx, getAuthIdentity, id) + var i AuthIdentity + err := row.Scan( + &i.ID, + &i.UserID, + &i.Platform, + &i.ExternalID, + &i.Name, + &i.LinkedAt, + ) + return i, err +} + +const getAuthIdentityByPlatform = `-- name: GetAuthIdentityByPlatform :one +SELECT id, user_id, platform, external_id, name, linked_at FROM auth_identities WHERE platform = ? AND external_id = ? +` + +type GetAuthIdentityByPlatformParams struct { + Platform string `json:"platform"` + ExternalID string `json:"external_id"` +} + +func (q *Queries) GetAuthIdentityByPlatform(ctx context.Context, arg GetAuthIdentityByPlatformParams) (AuthIdentity, error) { + row := q.db.QueryRowContext(ctx, getAuthIdentityByPlatform, arg.Platform, arg.ExternalID) + var i AuthIdentity + err := row.Scan( + &i.ID, + &i.UserID, + &i.Platform, + &i.ExternalID, + &i.Name, + &i.LinkedAt, + ) + return i, err +} + +const listAuthIdentitiesByUser = `-- name: ListAuthIdentitiesByUser :many +SELECT id, user_id, platform, external_id, name, linked_at FROM auth_identities WHERE user_id = ? ORDER BY linked_at +` + +func (q *Queries) ListAuthIdentitiesByUser(ctx context.Context, userID int64) ([]AuthIdentity, error) { + rows, err := q.db.QueryContext(ctx, listAuthIdentitiesByUser, userID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []AuthIdentity{} + for rows.Next() { + var i AuthIdentity + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.Platform, + &i.ExternalID, + &i.Name, + &i.LinkedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/db/sqlc/auth_policies.sql.go b/internal/db/sqlc/auth_policies.sql.go new file mode 100644 index 00000000..7d941e2c --- /dev/null +++ b/internal/db/sqlc/auth_policies.sql.go @@ -0,0 +1,209 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: auth_policies.sql + +package sqlc + +import ( + "context" +) + +const createAuthPolicy = `-- name: CreateAuthPolicy :one +INSERT INTO auth_policies (id, name, effect, subjects, actions, resources, conditions, priority, is_system, enabled) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +RETURNING id, name, effect, subjects, actions, resources, conditions, priority, is_system, enabled, created_at +` + +type CreateAuthPolicyParams struct { + ID string `json:"id"` + Name string `json:"name"` + Effect string `json:"effect"` + Subjects string `json:"subjects"` + Actions string `json:"actions"` + Resources string `json:"resources"` + Conditions string `json:"conditions"` + Priority int64 `json:"priority"` + IsSystem int64 `json:"is_system"` + Enabled int64 `json:"enabled"` +} + +func (q *Queries) CreateAuthPolicy(ctx context.Context, arg CreateAuthPolicyParams) (AuthPolicy, error) { + row := q.db.QueryRowContext(ctx, createAuthPolicy, + arg.ID, + arg.Name, + arg.Effect, + arg.Subjects, + arg.Actions, + arg.Resources, + arg.Conditions, + arg.Priority, + arg.IsSystem, + arg.Enabled, + ) + var i AuthPolicy + err := row.Scan( + &i.ID, + &i.Name, + &i.Effect, + &i.Subjects, + &i.Actions, + &i.Resources, + &i.Conditions, + &i.Priority, + &i.IsSystem, + &i.Enabled, + &i.CreatedAt, + ) + return i, err +} + +const deleteAuthPolicy = `-- name: DeleteAuthPolicy :exec +DELETE FROM auth_policies WHERE id = ? +` + +func (q *Queries) DeleteAuthPolicy(ctx context.Context, id string) error { + _, err := q.db.ExecContext(ctx, deleteAuthPolicy, id) + return err +} + +const getAuthPolicy = `-- name: GetAuthPolicy :one +SELECT id, name, effect, subjects, actions, resources, conditions, priority, is_system, enabled, created_at FROM auth_policies WHERE id = ? +` + +func (q *Queries) GetAuthPolicy(ctx context.Context, id string) (AuthPolicy, error) { + row := q.db.QueryRowContext(ctx, getAuthPolicy, id) + var i AuthPolicy + err := row.Scan( + &i.ID, + &i.Name, + &i.Effect, + &i.Subjects, + &i.Actions, + &i.Resources, + &i.Conditions, + &i.Priority, + &i.IsSystem, + &i.Enabled, + &i.CreatedAt, + ) + return i, err +} + +const listAuthPolicies = `-- name: ListAuthPolicies :many +SELECT id, name, effect, subjects, actions, resources, conditions, priority, is_system, enabled, created_at FROM auth_policies ORDER BY priority DESC, name +` + +func (q *Queries) ListAuthPolicies(ctx context.Context) ([]AuthPolicy, error) { + rows, err := q.db.QueryContext(ctx, listAuthPolicies) + if err != nil { + return nil, err + } + defer rows.Close() + items := []AuthPolicy{} + for rows.Next() { + var i AuthPolicy + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Effect, + &i.Subjects, + &i.Actions, + &i.Resources, + &i.Conditions, + &i.Priority, + &i.IsSystem, + &i.Enabled, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listEnabledAuthPolicies = `-- name: ListEnabledAuthPolicies :many +SELECT id, name, effect, subjects, actions, resources, conditions, priority, is_system, enabled, created_at FROM auth_policies WHERE enabled = 1 ORDER BY priority DESC, name +` + +func (q *Queries) ListEnabledAuthPolicies(ctx context.Context) ([]AuthPolicy, error) { + rows, err := q.db.QueryContext(ctx, listEnabledAuthPolicies) + if err != nil { + return nil, err + } + defer rows.Close() + items := []AuthPolicy{} + for rows.Next() { + var i AuthPolicy + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Effect, + &i.Subjects, + &i.Actions, + &i.Resources, + &i.Conditions, + &i.Priority, + &i.IsSystem, + &i.Enabled, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateAuthPolicy = `-- name: UpdateAuthPolicy :exec +UPDATE auth_policies SET + name = ?, + effect = ?, + subjects = ?, + actions = ?, + resources = ?, + conditions = ?, + priority = ?, + enabled = ? +WHERE id = ? +` + +type UpdateAuthPolicyParams struct { + Name string `json:"name"` + Effect string `json:"effect"` + Subjects string `json:"subjects"` + Actions string `json:"actions"` + Resources string `json:"resources"` + Conditions string `json:"conditions"` + Priority int64 `json:"priority"` + Enabled int64 `json:"enabled"` + ID string `json:"id"` +} + +func (q *Queries) UpdateAuthPolicy(ctx context.Context, arg UpdateAuthPolicyParams) error { + _, err := q.db.ExecContext(ctx, updateAuthPolicy, + arg.Name, + arg.Effect, + arg.Subjects, + arg.Actions, + arg.Resources, + arg.Conditions, + arg.Priority, + arg.Enabled, + arg.ID, + ) + return err +} diff --git a/internal/db/sqlc/auth_roles.sql.go b/internal/db/sqlc/auth_roles.sql.go new file mode 100644 index 00000000..0db8d56a --- /dev/null +++ b/internal/db/sqlc/auth_roles.sql.go @@ -0,0 +1,118 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: auth_roles.sql + +package sqlc + +import ( + "context" +) + +const createAuthRole = `-- name: CreateAuthRole :one +INSERT INTO auth_roles (id, name, description, is_system) +VALUES (?, ?, ?, ?) +RETURNING id, name, description, is_system, created_at +` + +type CreateAuthRoleParams struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + IsSystem int64 `json:"is_system"` +} + +func (q *Queries) CreateAuthRole(ctx context.Context, arg CreateAuthRoleParams) (AuthRole, error) { + row := q.db.QueryRowContext(ctx, createAuthRole, + arg.ID, + arg.Name, + arg.Description, + arg.IsSystem, + ) + var i AuthRole + err := row.Scan( + &i.ID, + &i.Name, + &i.Description, + &i.IsSystem, + &i.CreatedAt, + ) + return i, err +} + +const deleteAuthRole = `-- name: DeleteAuthRole :exec +DELETE FROM auth_roles WHERE id = ? +` + +func (q *Queries) DeleteAuthRole(ctx context.Context, id string) error { + _, err := q.db.ExecContext(ctx, deleteAuthRole, id) + return err +} + +const getAuthRole = `-- name: GetAuthRole :one +SELECT id, name, description, is_system, created_at FROM auth_roles WHERE id = ? +` + +func (q *Queries) GetAuthRole(ctx context.Context, id string) (AuthRole, error) { + row := q.db.QueryRowContext(ctx, getAuthRole, id) + var i AuthRole + err := row.Scan( + &i.ID, + &i.Name, + &i.Description, + &i.IsSystem, + &i.CreatedAt, + ) + return i, err +} + +const listAuthRoles = `-- name: ListAuthRoles :many +SELECT id, name, description, is_system, created_at FROM auth_roles ORDER BY name +` + +func (q *Queries) ListAuthRoles(ctx context.Context) ([]AuthRole, error) { + rows, err := q.db.QueryContext(ctx, listAuthRoles) + if err != nil { + return nil, err + } + defer rows.Close() + items := []AuthRole{} + for rows.Next() { + var i AuthRole + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Description, + &i.IsSystem, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateAuthRole = `-- name: UpdateAuthRole :exec +UPDATE auth_roles SET + name = ?, + description = ? +WHERE id = ? +` + +type UpdateAuthRoleParams struct { + Name string `json:"name"` + Description string `json:"description"` + ID string `json:"id"` +} + +func (q *Queries) UpdateAuthRole(ctx context.Context, arg UpdateAuthRoleParams) error { + _, err := q.db.ExecContext(ctx, updateAuthRole, arg.Name, arg.Description, arg.ID) + return err +} diff --git a/internal/db/sqlc/auth_sessions.sql.go b/internal/db/sqlc/auth_sessions.sql.go new file mode 100644 index 00000000..25a3d770 --- /dev/null +++ b/internal/db/sqlc/auth_sessions.sql.go @@ -0,0 +1,91 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: auth_sessions.sql + +package sqlc + +import ( + "context" +) + +const createAuthSession = `-- name: CreateAuthSession :one +INSERT INTO auth_sessions (id, user_id, expires_at) +VALUES (?, ?, ?) +RETURNING id, user_id, expires_at, created_at +` + +type CreateAuthSessionParams struct { + ID string `json:"id"` + UserID int64 `json:"user_id"` + ExpiresAt string `json:"expires_at"` +} + +func (q *Queries) CreateAuthSession(ctx context.Context, arg CreateAuthSessionParams) (AuthSession, error) { + row := q.db.QueryRowContext(ctx, createAuthSession, arg.ID, arg.UserID, arg.ExpiresAt) + var i AuthSession + err := row.Scan( + &i.ID, + &i.UserID, + &i.ExpiresAt, + &i.CreatedAt, + ) + return i, err +} + +const deleteAuthSession = `-- name: DeleteAuthSession :exec +DELETE FROM auth_sessions WHERE id = ? +` + +func (q *Queries) DeleteAuthSession(ctx context.Context, id string) error { + _, err := q.db.ExecContext(ctx, deleteAuthSession, id) + return err +} + +const deleteExpiredAuthSessions = `-- name: DeleteExpiredAuthSessions :exec +DELETE FROM auth_sessions WHERE expires_at < datetime('now') +` + +func (q *Queries) DeleteExpiredAuthSessions(ctx context.Context) error { + _, err := q.db.ExecContext(ctx, deleteExpiredAuthSessions) + return err +} + +const deleteUserAuthSessions = `-- name: DeleteUserAuthSessions :exec +DELETE FROM auth_sessions WHERE user_id = ? +` + +func (q *Queries) DeleteUserAuthSessions(ctx context.Context, userID int64) error { + _, err := q.db.ExecContext(ctx, deleteUserAuthSessions, userID) + return err +} + +const getAuthSession = `-- name: GetAuthSession :one +SELECT id, user_id, expires_at, created_at FROM auth_sessions WHERE id = ? +` + +func (q *Queries) GetAuthSession(ctx context.Context, id string) (AuthSession, error) { + row := q.db.QueryRowContext(ctx, getAuthSession, id) + var i AuthSession + err := row.Scan( + &i.ID, + &i.UserID, + &i.ExpiresAt, + &i.CreatedAt, + ) + return i, err +} + +const updateAuthSessionExpiry = `-- name: UpdateAuthSessionExpiry :exec +UPDATE auth_sessions SET expires_at = ? WHERE id = ? +` + +type UpdateAuthSessionExpiryParams struct { + ExpiresAt string `json:"expires_at"` + ID string `json:"id"` +} + +func (q *Queries) UpdateAuthSessionExpiry(ctx context.Context, arg UpdateAuthSessionExpiryParams) error { + _, err := q.db.ExecContext(ctx, updateAuthSessionExpiry, arg.ExpiresAt, arg.ID) + return err +} diff --git a/internal/db/sqlc/auth_user_agents.sql.go b/internal/db/sqlc/auth_user_agents.sql.go new file mode 100644 index 00000000..d1e1e303 --- /dev/null +++ b/internal/db/sqlc/auth_user_agents.sql.go @@ -0,0 +1,94 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: auth_user_agents.sql + +package sqlc + +import ( + "context" +) + +const assignUserAgent = `-- name: AssignUserAgent :exec +INSERT INTO auth_user_agents (user_id, agent_id) +VALUES (?, ?) +ON CONFLICT DO NOTHING +` + +type AssignUserAgentParams struct { + UserID int64 `json:"user_id"` + AgentID string `json:"agent_id"` +} + +func (q *Queries) AssignUserAgent(ctx context.Context, arg AssignUserAgentParams) error { + _, err := q.db.ExecContext(ctx, assignUserAgent, arg.UserID, arg.AgentID) + return err +} + +const listAgentUsers = `-- name: ListAgentUsers :many +SELECT user_id FROM auth_user_agents WHERE agent_id = ? ORDER BY user_id +` + +func (q *Queries) ListAgentUsers(ctx context.Context, agentID string) ([]int64, error) { + rows, err := q.db.QueryContext(ctx, listAgentUsers, agentID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []int64{} + for rows.Next() { + var user_id int64 + if err := rows.Scan(&user_id); err != nil { + return nil, err + } + items = append(items, user_id) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listUserAgents = `-- name: ListUserAgents :many +SELECT agent_id FROM auth_user_agents WHERE user_id = ? ORDER BY agent_id +` + +func (q *Queries) ListUserAgents(ctx context.Context, userID int64) ([]string, error) { + rows, err := q.db.QueryContext(ctx, listUserAgents, userID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []string{} + for rows.Next() { + var agent_id string + if err := rows.Scan(&agent_id); err != nil { + return nil, err + } + items = append(items, agent_id) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const removeUserAgent = `-- name: RemoveUserAgent :exec +DELETE FROM auth_user_agents WHERE user_id = ? AND agent_id = ? +` + +type RemoveUserAgentParams struct { + UserID int64 `json:"user_id"` + AgentID string `json:"agent_id"` +} + +func (q *Queries) RemoveUserAgent(ctx context.Context, arg RemoveUserAgentParams) error { + _, err := q.db.ExecContext(ctx, removeUserAgent, arg.UserID, arg.AgentID) + return err +} diff --git a/internal/db/sqlc/auth_user_roles.sql.go b/internal/db/sqlc/auth_user_roles.sql.go new file mode 100644 index 00000000..52fabf58 --- /dev/null +++ b/internal/db/sqlc/auth_user_roles.sql.go @@ -0,0 +1,113 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: auth_user_roles.sql + +package sqlc + +import ( + "context" +) + +const assignUserRole = `-- name: AssignUserRole :exec +INSERT INTO auth_user_roles (user_id, role_id) +VALUES (?, ?) +ON CONFLICT DO NOTHING +` + +type AssignUserRoleParams struct { + UserID int64 `json:"user_id"` + RoleID string `json:"role_id"` +} + +func (q *Queries) AssignUserRole(ctx context.Context, arg AssignUserRoleParams) error { + _, err := q.db.ExecContext(ctx, assignUserRole, arg.UserID, arg.RoleID) + return err +} + +const listRoleUsers = `-- name: ListRoleUsers :many +SELECT u.id, u.username, u.password_hash, u.is_active, u.created_at, u.updated_at FROM auth_users u +JOIN auth_user_roles ur ON ur.user_id = u.id +WHERE ur.role_id = ? +ORDER BY u.username +` + +func (q *Queries) ListRoleUsers(ctx context.Context, roleID string) ([]AuthUser, error) { + rows, err := q.db.QueryContext(ctx, listRoleUsers, roleID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []AuthUser{} + for rows.Next() { + var i AuthUser + if err := rows.Scan( + &i.ID, + &i.Username, + &i.PasswordHash, + &i.IsActive, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listUserRoles = `-- name: ListUserRoles :many +SELECT r.id, r.name, r.description, r.is_system, r.created_at FROM auth_roles r +JOIN auth_user_roles ur ON ur.role_id = r.id +WHERE ur.user_id = ? +ORDER BY r.name +` + +func (q *Queries) ListUserRoles(ctx context.Context, userID int64) ([]AuthRole, error) { + rows, err := q.db.QueryContext(ctx, listUserRoles, userID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []AuthRole{} + for rows.Next() { + var i AuthRole + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Description, + &i.IsSystem, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const removeUserRole = `-- name: RemoveUserRole :exec +DELETE FROM auth_user_roles WHERE user_id = ? AND role_id = ? +` + +type RemoveUserRoleParams struct { + UserID int64 `json:"user_id"` + RoleID string `json:"role_id"` +} + +func (q *Queries) RemoveUserRole(ctx context.Context, arg RemoveUserRoleParams) error { + _, err := q.db.ExecContext(ctx, removeUserRole, arg.UserID, arg.RoleID) + return err +} diff --git a/internal/db/sqlc/auth_users.sql.go b/internal/db/sqlc/auth_users.sql.go new file mode 100644 index 00000000..b07c4fed --- /dev/null +++ b/internal/db/sqlc/auth_users.sql.go @@ -0,0 +1,151 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: auth_users.sql + +package sqlc + +import ( + "context" +) + +const countAuthUsers = `-- name: CountAuthUsers :one +SELECT COUNT(*) FROM auth_users +` + +func (q *Queries) CountAuthUsers(ctx context.Context) (int64, error) { + row := q.db.QueryRowContext(ctx, countAuthUsers) + var count int64 + err := row.Scan(&count) + return count, err +} + +const createAuthUser = `-- name: CreateAuthUser :one +INSERT INTO auth_users (username, password_hash) +VALUES (?, ?) +RETURNING id, username, password_hash, is_active, created_at, updated_at +` + +type CreateAuthUserParams struct { + Username string `json:"username"` + PasswordHash string `json:"password_hash"` +} + +func (q *Queries) CreateAuthUser(ctx context.Context, arg CreateAuthUserParams) (AuthUser, error) { + row := q.db.QueryRowContext(ctx, createAuthUser, arg.Username, arg.PasswordHash) + var i AuthUser + err := row.Scan( + &i.ID, + &i.Username, + &i.PasswordHash, + &i.IsActive, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const deleteAuthUser = `-- name: DeleteAuthUser :exec +DELETE FROM auth_users WHERE id = ? +` + +func (q *Queries) DeleteAuthUser(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, deleteAuthUser, id) + return err +} + +const getAuthUser = `-- name: GetAuthUser :one +SELECT id, username, password_hash, is_active, created_at, updated_at FROM auth_users WHERE id = ? +` + +func (q *Queries) GetAuthUser(ctx context.Context, id int64) (AuthUser, error) { + row := q.db.QueryRowContext(ctx, getAuthUser, id) + var i AuthUser + err := row.Scan( + &i.ID, + &i.Username, + &i.PasswordHash, + &i.IsActive, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getAuthUserByUsername = `-- name: GetAuthUserByUsername :one +SELECT id, username, password_hash, is_active, created_at, updated_at FROM auth_users WHERE username = ? +` + +func (q *Queries) GetAuthUserByUsername(ctx context.Context, username string) (AuthUser, error) { + row := q.db.QueryRowContext(ctx, getAuthUserByUsername, username) + var i AuthUser + err := row.Scan( + &i.ID, + &i.Username, + &i.PasswordHash, + &i.IsActive, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listAuthUsers = `-- name: ListAuthUsers :many +SELECT id, username, password_hash, is_active, created_at, updated_at FROM auth_users ORDER BY username +` + +func (q *Queries) ListAuthUsers(ctx context.Context) ([]AuthUser, error) { + rows, err := q.db.QueryContext(ctx, listAuthUsers) + if err != nil { + return nil, err + } + defer rows.Close() + items := []AuthUser{} + for rows.Next() { + var i AuthUser + if err := rows.Scan( + &i.ID, + &i.Username, + &i.PasswordHash, + &i.IsActive, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateAuthUser = `-- name: UpdateAuthUser :exec +UPDATE auth_users SET + username = ?, + password_hash = ?, + is_active = ?, + updated_at = datetime('now') +WHERE id = ? +` + +type UpdateAuthUserParams struct { + Username string `json:"username"` + PasswordHash string `json:"password_hash"` + IsActive int64 `json:"is_active"` + ID int64 `json:"id"` +} + +func (q *Queries) UpdateAuthUser(ctx context.Context, arg UpdateAuthUserParams) error { + _, err := q.db.ExecContext(ctx, updateAuthUser, + arg.Username, + arg.PasswordHash, + arg.IsActive, + arg.ID, + ) + return err +} diff --git a/internal/db/sqlc/models.go b/internal/db/sqlc/models.go index efc6814d..fcdefdc3 100644 --- a/internal/db/sqlc/models.go +++ b/internal/db/sqlc/models.go @@ -8,6 +8,63 @@ import ( "database/sql" ) +type AuthIdentity struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + Platform string `json:"platform"` + ExternalID string `json:"external_id"` + Name string `json:"name"` + LinkedAt string `json:"linked_at"` +} + +type AuthPolicy struct { + ID string `json:"id"` + Name string `json:"name"` + Effect string `json:"effect"` + Subjects string `json:"subjects"` + Actions string `json:"actions"` + Resources string `json:"resources"` + Conditions string `json:"conditions"` + Priority int64 `json:"priority"` + IsSystem int64 `json:"is_system"` + Enabled int64 `json:"enabled"` + CreatedAt string `json:"created_at"` +} + +type AuthRole struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + IsSystem int64 `json:"is_system"` + CreatedAt string `json:"created_at"` +} + +type AuthSession struct { + ID string `json:"id"` + UserID int64 `json:"user_id"` + ExpiresAt string `json:"expires_at"` + CreatedAt string `json:"created_at"` +} + +type AuthUser struct { + ID int64 `json:"id"` + Username string `json:"username"` + PasswordHash string `json:"password_hash"` + IsActive int64 `json:"is_active"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +type AuthUserAgent struct { + UserID int64 `json:"user_id"` + AgentID string `json:"agent_id"` +} + +type AuthUserRole struct { + UserID int64 `json:"user_id"` + RoleID string `json:"role_id"` +} + type CtxAgentMemory struct { UserID int64 `json:"user_id"` AgentID string `json:"agent_id"` @@ -117,6 +174,7 @@ type SettingsAgent struct { ModelFast string `json:"model_fast"` SystemPrompt string `json:"system_prompt"` Workspace string `json:"workspace"` + Scope string `json:"scope"` Enabled int64 `json:"enabled"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` diff --git a/internal/db/sqlc/settings_agents.sql.go b/internal/db/sqlc/settings_agents.sql.go index 3b9657d7..90cf995d 100644 --- a/internal/db/sqlc/settings_agents.sql.go +++ b/internal/db/sqlc/settings_agents.sql.go @@ -12,7 +12,7 @@ import ( const createAgent = `-- name: CreateAgent :one INSERT INTO settings_agents (id, name, model, model_strong, model_fast, system_prompt, workspace, enabled) VALUES (?, ?, ?, ?, ?, ?, ?, ?) -RETURNING id, name, model, model_strong, model_fast, system_prompt, workspace, enabled, created_at, updated_at +RETURNING id, name, model, model_strong, model_fast, system_prompt, workspace, scope, enabled, created_at, updated_at ` type CreateAgentParams struct { @@ -46,6 +46,7 @@ func (q *Queries) CreateAgent(ctx context.Context, arg CreateAgentParams) (Setti &i.ModelFast, &i.SystemPrompt, &i.Workspace, + &i.Scope, &i.Enabled, &i.CreatedAt, &i.UpdatedAt, @@ -63,7 +64,7 @@ func (q *Queries) DeleteAgent(ctx context.Context, id string) error { } const getAgent = `-- name: GetAgent :one -SELECT id, name, model, model_strong, model_fast, system_prompt, workspace, enabled, created_at, updated_at FROM settings_agents WHERE id = ? +SELECT id, name, model, model_strong, model_fast, system_prompt, workspace, scope, enabled, created_at, updated_at FROM settings_agents WHERE id = ? ` func (q *Queries) GetAgent(ctx context.Context, id string) (SettingsAgent, error) { @@ -77,6 +78,7 @@ func (q *Queries) GetAgent(ctx context.Context, id string) (SettingsAgent, error &i.ModelFast, &i.SystemPrompt, &i.Workspace, + &i.Scope, &i.Enabled, &i.CreatedAt, &i.UpdatedAt, @@ -85,7 +87,7 @@ func (q *Queries) GetAgent(ctx context.Context, id string) (SettingsAgent, error } const listAgents = `-- name: ListAgents :many -SELECT id, name, model, model_strong, model_fast, system_prompt, workspace, enabled, created_at, updated_at FROM settings_agents ORDER BY name +SELECT id, name, model, model_strong, model_fast, system_prompt, workspace, scope, enabled, created_at, updated_at FROM settings_agents ORDER BY name ` func (q *Queries) ListAgents(ctx context.Context) ([]SettingsAgent, error) { @@ -105,6 +107,7 @@ func (q *Queries) ListAgents(ctx context.Context) ([]SettingsAgent, error) { &i.ModelFast, &i.SystemPrompt, &i.Workspace, + &i.Scope, &i.Enabled, &i.CreatedAt, &i.UpdatedAt, @@ -123,7 +126,7 @@ func (q *Queries) ListAgents(ctx context.Context) ([]SettingsAgent, error) { } const listEnabledAgents = `-- name: ListEnabledAgents :many -SELECT id, name, model, model_strong, model_fast, system_prompt, workspace, enabled, created_at, updated_at FROM settings_agents WHERE enabled = 1 ORDER BY name +SELECT id, name, model, model_strong, model_fast, system_prompt, workspace, scope, enabled, created_at, updated_at FROM settings_agents WHERE enabled = 1 ORDER BY name ` func (q *Queries) ListEnabledAgents(ctx context.Context) ([]SettingsAgent, error) { @@ -143,6 +146,7 @@ func (q *Queries) ListEnabledAgents(ctx context.Context) ([]SettingsAgent, error &i.ModelFast, &i.SystemPrompt, &i.Workspace, + &i.Scope, &i.Enabled, &i.CreatedAt, &i.UpdatedAt, From b7152953ff4b2a3468f559454c81cd7a9a74bee2 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Fri, 20 Mar 2026 18:44:18 +0800 Subject: [PATCH 03/53] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20auth=20package?= =?UTF-8?q?=20with=20types,=20password=20hashing,=20store=20interface,=20a?= =?UTF-8?q?nd=20DB=20implementation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - types.go: AuthUser, Role, Policy, AccessRequest, Subject, Resource, Action, Identity, Session - password.go: bcrypt hash/verify with cost=12 - store.go: AuthStore interface (separate from config.Store) - authdb/store.go: SQLite implementation using sqlc queries --- internal/auth/authdb/store.go | 442 ++++++++++++++++++++++++++++++++++ internal/auth/password.go | 19 ++ internal/auth/store.go | 57 +++++ internal/auth/types.go | 121 ++++++++++ 4 files changed, 639 insertions(+) create mode 100644 internal/auth/authdb/store.go create mode 100644 internal/auth/password.go create mode 100644 internal/auth/store.go create mode 100644 internal/auth/types.go diff --git a/internal/auth/authdb/store.go b/internal/auth/authdb/store.go new file mode 100644 index 00000000..8b060780 --- /dev/null +++ b/internal/auth/authdb/store.go @@ -0,0 +1,442 @@ +package authdb + +import ( + "context" + "database/sql" + "fmt" + "time" + + "github.com/vaayne/anna/internal/auth" + "github.com/vaayne/anna/internal/db/sqlc" +) + +const timeLayout = "2006-01-02 15:04:05" + +// Store implements auth.AuthStore using sqlc queries backed by SQLite. +type Store struct { + q *sqlc.Queries +} + +// New creates a new authdb.Store wrapping the given database connection. +func New(db *sql.DB) *Store { + return &Store{q: sqlc.New(db)} +} + +// --- Users --- + +func (s *Store) CreateUser(ctx context.Context, username, passwordHash string) (auth.AuthUser, error) { + r, err := s.q.CreateAuthUser(ctx, sqlc.CreateAuthUserParams{ + Username: username, + PasswordHash: passwordHash, + }) + if err != nil { + return auth.AuthUser{}, fmt.Errorf("create auth user %q: %w", username, err) + } + return userFromDB(r), nil +} + +func (s *Store) GetUser(ctx context.Context, id int64) (auth.AuthUser, error) { + r, err := s.q.GetAuthUser(ctx, id) + if err != nil { + return auth.AuthUser{}, fmt.Errorf("get auth user %d: %w", id, err) + } + return userFromDB(r), nil +} + +func (s *Store) GetUserByUsername(ctx context.Context, username string) (auth.AuthUser, error) { + r, err := s.q.GetAuthUserByUsername(ctx, username) + if err != nil { + return auth.AuthUser{}, fmt.Errorf("get auth user by username %q: %w", username, err) + } + return userFromDB(r), nil +} + +func (s *Store) ListUsers(ctx context.Context) ([]auth.AuthUser, error) { + rows, err := s.q.ListAuthUsers(ctx) + if err != nil { + return nil, fmt.Errorf("list auth users: %w", err) + } + out := make([]auth.AuthUser, len(rows)) + for i, r := range rows { + out[i] = userFromDB(r) + } + return out, nil +} + +func (s *Store) UpdateUser(ctx context.Context, u auth.AuthUser) error { + isActive := int64(0) + if u.IsActive { + isActive = 1 + } + return s.q.UpdateAuthUser(ctx, sqlc.UpdateAuthUserParams{ + ID: u.ID, + Username: u.Username, + PasswordHash: u.PasswordHash, + IsActive: isActive, + }) +} + +func (s *Store) DeleteUser(ctx context.Context, id int64) error { + return s.q.DeleteAuthUser(ctx, id) +} + +func (s *Store) CountUsers(ctx context.Context) (int64, error) { + return s.q.CountAuthUsers(ctx) +} + +// --- Roles --- + +func (s *Store) CreateRole(ctx context.Context, r auth.Role) (auth.Role, error) { + isSystem := int64(0) + if r.IsSystem { + isSystem = 1 + } + row, err := s.q.CreateAuthRole(ctx, sqlc.CreateAuthRoleParams{ + ID: r.ID, + Name: r.Name, + Description: r.Description, + IsSystem: isSystem, + }) + if err != nil { + return auth.Role{}, fmt.Errorf("create auth role %q: %w", r.ID, err) + } + return roleFromDB(row), nil +} + +func (s *Store) GetRole(ctx context.Context, id string) (auth.Role, error) { + r, err := s.q.GetAuthRole(ctx, id) + if err != nil { + return auth.Role{}, fmt.Errorf("get auth role %q: %w", id, err) + } + return roleFromDB(r), nil +} + +func (s *Store) ListRoles(ctx context.Context) ([]auth.Role, error) { + rows, err := s.q.ListAuthRoles(ctx) + if err != nil { + return nil, fmt.Errorf("list auth roles: %w", err) + } + out := make([]auth.Role, len(rows)) + for i, r := range rows { + out[i] = roleFromDB(r) + } + return out, nil +} + +func (s *Store) UpdateRole(ctx context.Context, r auth.Role) error { + return s.q.UpdateAuthRole(ctx, sqlc.UpdateAuthRoleParams{ + ID: r.ID, + Name: r.Name, + Description: r.Description, + }) +} + +func (s *Store) DeleteRole(ctx context.Context, id string) error { + return s.q.DeleteAuthRole(ctx, id) +} + +// --- User-Role assignments --- + +func (s *Store) AssignRole(ctx context.Context, userID int64, roleID string) error { + return s.q.AssignUserRole(ctx, sqlc.AssignUserRoleParams{ + UserID: userID, + RoleID: roleID, + }) +} + +func (s *Store) RemoveRole(ctx context.Context, userID int64, roleID string) error { + return s.q.RemoveUserRole(ctx, sqlc.RemoveUserRoleParams{ + UserID: userID, + RoleID: roleID, + }) +} + +func (s *Store) ListUserRoles(ctx context.Context, userID int64) ([]auth.Role, error) { + rows, err := s.q.ListUserRoles(ctx, userID) + if err != nil { + return nil, fmt.Errorf("list user roles for user %d: %w", userID, err) + } + out := make([]auth.Role, len(rows)) + for i, r := range rows { + out[i] = roleFromDB(r) + } + return out, nil +} + +// --- Identities --- + +func (s *Store) CreateIdentity(ctx context.Context, i auth.Identity) (auth.Identity, error) { + r, err := s.q.CreateAuthIdentity(ctx, sqlc.CreateAuthIdentityParams{ + UserID: i.UserID, + Platform: i.Platform, + ExternalID: i.ExternalID, + Name: i.Name, + }) + if err != nil { + return auth.Identity{}, fmt.Errorf("create identity: %w", err) + } + return identityFromDB(r), nil +} + +func (s *Store) GetIdentity(ctx context.Context, id int64) (auth.Identity, error) { + r, err := s.q.GetAuthIdentity(ctx, id) + if err != nil { + return auth.Identity{}, fmt.Errorf("get identity %d: %w", id, err) + } + return identityFromDB(r), nil +} + +func (s *Store) GetIdentityByPlatform(ctx context.Context, platform, externalID string) (auth.Identity, error) { + r, err := s.q.GetAuthIdentityByPlatform(ctx, sqlc.GetAuthIdentityByPlatformParams{ + Platform: platform, + ExternalID: externalID, + }) + if err != nil { + return auth.Identity{}, fmt.Errorf("get identity by platform %s/%s: %w", platform, externalID, err) + } + return identityFromDB(r), nil +} + +func (s *Store) ListIdentitiesByUser(ctx context.Context, userID int64) ([]auth.Identity, error) { + rows, err := s.q.ListAuthIdentitiesByUser(ctx, userID) + if err != nil { + return nil, fmt.Errorf("list identities for user %d: %w", userID, err) + } + out := make([]auth.Identity, len(rows)) + for i, r := range rows { + out[i] = identityFromDB(r) + } + return out, nil +} + +func (s *Store) DeleteIdentity(ctx context.Context, id int64) error { + return s.q.DeleteAuthIdentity(ctx, id) +} + +// --- Policies --- + +func (s *Store) CreatePolicy(ctx context.Context, p auth.Policy) (auth.Policy, error) { + isSystem := int64(0) + if p.IsSystem { + isSystem = 1 + } + enabled := int64(0) + if p.Enabled { + enabled = 1 + } + r, err := s.q.CreateAuthPolicy(ctx, sqlc.CreateAuthPolicyParams{ + ID: p.ID, + Name: p.Name, + Effect: p.Effect, + Subjects: p.Subjects, + Actions: p.Actions, + Resources: p.Resources, + Conditions: p.Conditions, + Priority: int64(p.Priority), + IsSystem: isSystem, + Enabled: enabled, + }) + if err != nil { + return auth.Policy{}, fmt.Errorf("create policy %q: %w", p.ID, err) + } + return policyFromDB(r), nil +} + +func (s *Store) GetPolicy(ctx context.Context, id string) (auth.Policy, error) { + r, err := s.q.GetAuthPolicy(ctx, id) + if err != nil { + return auth.Policy{}, fmt.Errorf("get policy %q: %w", id, err) + } + return policyFromDB(r), nil +} + +func (s *Store) ListPolicies(ctx context.Context) ([]auth.Policy, error) { + rows, err := s.q.ListAuthPolicies(ctx) + if err != nil { + return nil, fmt.Errorf("list policies: %w", err) + } + out := make([]auth.Policy, len(rows)) + for i, r := range rows { + out[i] = policyFromDB(r) + } + return out, nil +} + +func (s *Store) ListEnabledPolicies(ctx context.Context) ([]auth.Policy, error) { + rows, err := s.q.ListEnabledAuthPolicies(ctx) + if err != nil { + return nil, fmt.Errorf("list enabled policies: %w", err) + } + out := make([]auth.Policy, len(rows)) + for i, r := range rows { + out[i] = policyFromDB(r) + } + return out, nil +} + +func (s *Store) UpdatePolicy(ctx context.Context, p auth.Policy) error { + enabled := int64(0) + if p.Enabled { + enabled = 1 + } + return s.q.UpdateAuthPolicy(ctx, sqlc.UpdateAuthPolicyParams{ + ID: p.ID, + Name: p.Name, + Effect: p.Effect, + Subjects: p.Subjects, + Actions: p.Actions, + Resources: p.Resources, + Conditions: p.Conditions, + Priority: int64(p.Priority), + Enabled: enabled, + }) +} + +func (s *Store) DeletePolicy(ctx context.Context, id string) error { + return s.q.DeleteAuthPolicy(ctx, id) +} + +// --- User-Agent assignments --- + +func (s *Store) AssignAgent(ctx context.Context, userID int64, agentID string) error { + return s.q.AssignUserAgent(ctx, sqlc.AssignUserAgentParams{ + UserID: userID, + AgentID: agentID, + }) +} + +func (s *Store) RemoveAgent(ctx context.Context, userID int64, agentID string) error { + return s.q.RemoveUserAgent(ctx, sqlc.RemoveUserAgentParams{ + UserID: userID, + AgentID: agentID, + }) +} + +func (s *Store) ListUserAgentIDs(ctx context.Context, userID int64) ([]string, error) { + rows, err := s.q.ListUserAgents(ctx, userID) + if err != nil { + return nil, fmt.Errorf("list user agents for user %d: %w", userID, err) + } + out := make([]string, len(rows)) + for i, r := range rows { + out[i] = r + } + return out, nil +} + +func (s *Store) ListAgentUserIDs(ctx context.Context, agentID string) ([]int64, error) { + rows, err := s.q.ListAgentUsers(ctx, agentID) + if err != nil { + return nil, fmt.Errorf("list agent users for agent %q: %w", agentID, err) + } + out := make([]int64, len(rows)) + for i, r := range rows { + out[i] = r + } + return out, nil +} + +// --- Sessions --- + +func (s *Store) CreateSession(ctx context.Context, sess auth.Session) (auth.Session, error) { + r, err := s.q.CreateAuthSession(ctx, sqlc.CreateAuthSessionParams{ + ID: sess.ID, + UserID: sess.UserID, + ExpiresAt: sess.ExpiresAt.UTC().Format(timeLayout), + }) + if err != nil { + return auth.Session{}, fmt.Errorf("create session: %w", err) + } + return sessionFromDB(r), nil +} + +func (s *Store) GetSession(ctx context.Context, id string) (auth.Session, error) { + r, err := s.q.GetAuthSession(ctx, id) + if err != nil { + return auth.Session{}, fmt.Errorf("get session %q: %w", id, err) + } + return sessionFromDB(r), nil +} + +func (s *Store) DeleteSession(ctx context.Context, id string) error { + return s.q.DeleteAuthSession(ctx, id) +} + +func (s *Store) DeleteExpiredSessions(ctx context.Context) error { + return s.q.DeleteExpiredAuthSessions(ctx) +} + +func (s *Store) DeleteUserSessions(ctx context.Context, userID int64) error { + return s.q.DeleteUserAuthSessions(ctx, userID) +} + +func (s *Store) UpdateSessionExpiry(ctx context.Context, id string, expiresAt string) error { + return s.q.UpdateAuthSessionExpiry(ctx, sqlc.UpdateAuthSessionExpiryParams{ + ID: id, + ExpiresAt: expiresAt, + }) +} + +// --- Helpers --- + +func parseTime(s string) time.Time { + t, _ := time.Parse(timeLayout, s) + return t +} + +func userFromDB(r sqlc.AuthUser) auth.AuthUser { + return auth.AuthUser{ + ID: r.ID, + Username: r.Username, + PasswordHash: r.PasswordHash, + IsActive: r.IsActive == 1, + CreatedAt: parseTime(r.CreatedAt), + UpdatedAt: parseTime(r.UpdatedAt), + } +} + +func roleFromDB(r sqlc.AuthRole) auth.Role { + return auth.Role{ + ID: r.ID, + Name: r.Name, + Description: r.Description, + IsSystem: r.IsSystem == 1, + CreatedAt: parseTime(r.CreatedAt), + } +} + +func identityFromDB(r sqlc.AuthIdentity) auth.Identity { + return auth.Identity{ + ID: r.ID, + UserID: r.UserID, + Platform: r.Platform, + ExternalID: r.ExternalID, + Name: r.Name, + LinkedAt: parseTime(r.LinkedAt), + } +} + +func policyFromDB(r sqlc.AuthPolicy) auth.Policy { + return auth.Policy{ + ID: r.ID, + Name: r.Name, + Effect: r.Effect, + Subjects: r.Subjects, + Actions: r.Actions, + Resources: r.Resources, + Conditions: r.Conditions, + Priority: int(r.Priority), + IsSystem: r.IsSystem == 1, + Enabled: r.Enabled == 1, + CreatedAt: parseTime(r.CreatedAt), + } +} + +func sessionFromDB(r sqlc.AuthSession) auth.Session { + return auth.Session{ + ID: r.ID, + UserID: r.UserID, + ExpiresAt: parseTime(r.ExpiresAt), + CreatedAt: parseTime(r.CreatedAt), + } +} diff --git a/internal/auth/password.go b/internal/auth/password.go new file mode 100644 index 00000000..b4cd4a73 --- /dev/null +++ b/internal/auth/password.go @@ -0,0 +1,19 @@ +package auth + +import "golang.org/x/crypto/bcrypt" + +const bcryptCost = 12 + +// HashPassword hashes a plaintext password using bcrypt with cost 12. +func HashPassword(plain string) (string, error) { + hash, err := bcrypt.GenerateFromPassword([]byte(plain), bcryptCost) + if err != nil { + return "", err + } + return string(hash), nil +} + +// CheckPassword verifies a plaintext password against a bcrypt hash. +func CheckPassword(hash, plain string) error { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(plain)) +} diff --git a/internal/auth/store.go b/internal/auth/store.go new file mode 100644 index 00000000..5b3f4262 --- /dev/null +++ b/internal/auth/store.go @@ -0,0 +1,57 @@ +package auth + +import "context" + +// AuthStore provides typed access to auth-related data in the database. +// This is separate from config.Store — auth methods are NOT mixed in. +type AuthStore interface { + // Users + CreateUser(ctx context.Context, username, passwordHash string) (AuthUser, error) + GetUser(ctx context.Context, id int64) (AuthUser, error) + GetUserByUsername(ctx context.Context, username string) (AuthUser, error) + ListUsers(ctx context.Context) ([]AuthUser, error) + UpdateUser(ctx context.Context, u AuthUser) error + DeleteUser(ctx context.Context, id int64) error + CountUsers(ctx context.Context) (int64, error) + + // Roles + CreateRole(ctx context.Context, r Role) (Role, error) + GetRole(ctx context.Context, id string) (Role, error) + ListRoles(ctx context.Context) ([]Role, error) + UpdateRole(ctx context.Context, r Role) error + DeleteRole(ctx context.Context, id string) error + + // User-Role assignments + AssignRole(ctx context.Context, userID int64, roleID string) error + RemoveRole(ctx context.Context, userID int64, roleID string) error + ListUserRoles(ctx context.Context, userID int64) ([]Role, error) + + // Identities (linked channel accounts) + CreateIdentity(ctx context.Context, i Identity) (Identity, error) + GetIdentity(ctx context.Context, id int64) (Identity, error) + GetIdentityByPlatform(ctx context.Context, platform, externalID string) (Identity, error) + ListIdentitiesByUser(ctx context.Context, userID int64) ([]Identity, error) + DeleteIdentity(ctx context.Context, id int64) error + + // Policies + CreatePolicy(ctx context.Context, p Policy) (Policy, error) + GetPolicy(ctx context.Context, id string) (Policy, error) + ListPolicies(ctx context.Context) ([]Policy, error) + ListEnabledPolicies(ctx context.Context) ([]Policy, error) + UpdatePolicy(ctx context.Context, p Policy) error + DeletePolicy(ctx context.Context, id string) error + + // User-Agent assignments + AssignAgent(ctx context.Context, userID int64, agentID string) error + RemoveAgent(ctx context.Context, userID int64, agentID string) error + ListUserAgentIDs(ctx context.Context, userID int64) ([]string, error) + ListAgentUserIDs(ctx context.Context, agentID string) ([]int64, error) + + // Sessions + CreateSession(ctx context.Context, s Session) (Session, error) + GetSession(ctx context.Context, id string) (Session, error) + DeleteSession(ctx context.Context, id string) error + DeleteExpiredSessions(ctx context.Context) error + DeleteUserSessions(ctx context.Context, userID int64) error + UpdateSessionExpiry(ctx context.Context, id string, expiresAt string) error +} diff --git a/internal/auth/types.go b/internal/auth/types.go new file mode 100644 index 00000000..2c5c9b19 --- /dev/null +++ b/internal/auth/types.go @@ -0,0 +1,121 @@ +package auth + +import "time" + +// AuthUser represents a system user with login credentials. +type AuthUser struct { + ID int64 `json:"id"` + Username string `json:"username"` + PasswordHash string `json:"-"` + IsActive bool `json:"is_active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Role represents an extensible role (e.g., admin, user). +type Role struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + IsSystem bool `json:"is_system"` + CreatedAt time.Time `json:"created_at"` +} + +// Policy represents an ABAC policy with JSON conditions. +type Policy struct { + ID string `json:"id"` + Name string `json:"name"` + Effect string `json:"effect"` + Subjects string `json:"subjects"` + Actions string `json:"actions"` + Resources string `json:"resources"` + Conditions string `json:"conditions"` + Priority int `json:"priority"` + IsSystem bool `json:"is_system"` + Enabled bool `json:"enabled"` + CreatedAt time.Time `json:"created_at"` +} + +// Policy effect constants. +const ( + EffectAllow = "allow" + EffectDeny = "deny" +) + +// AccessRequest represents a request to check authorization. +type AccessRequest struct { + Subject Subject `json:"subject"` + Action Action `json:"action"` + Resource Resource `json:"resource"` + Context map[string]any `json:"context,omitempty"` +} + +// Subject represents the entity requesting access. +type Subject struct { + UserID int64 `json:"user_id"` + Roles []string `json:"roles"` + AgentIDs []string `json:"agent_ids"` + Attrs map[string]any `json:"attrs,omitempty"` +} + +// Action is a string alias for authorization actions. +type Action string + +// Action constants. +const ( + ActionRead Action = "read" + ActionWrite Action = "write" + ActionCreate Action = "create" + ActionDelete Action = "delete" + ActionExecute Action = "execute" + ActionManage Action = "manage" +) + +// Resource represents the target of an authorization request. +type Resource struct { + Type ResourceType `json:"type"` + ID string `json:"id"` + OwnerID int64 `json:"owner_id"` + Attrs map[string]any `json:"attrs,omitempty"` +} + +// ResourceType is a string alias for resource types. +type ResourceType string + +// ResourceType constants. +const ( + ResourceAgent ResourceType = "agent" + ResourceAgentList ResourceType = "agent_list" + ResourceProvider ResourceType = "provider" + ResourceChannel ResourceType = "channel" + ResourceSession ResourceType = "session" + ResourceUser ResourceType = "user" + ResourceUserData ResourceType = "user_data" + ResourceSkill ResourceType = "skill" + ResourceScheduler ResourceType = "scheduler" + ResourceSetting ResourceType = "setting" +) + +// Identity represents a linked channel identity. +type Identity struct { + ID int64 `json:"id"` + UserID int64 `json:"user_id"` + Platform string `json:"platform"` + ExternalID string `json:"external_id"` + Name string `json:"name"` + LinkedAt time.Time `json:"linked_at"` +} + +// Session represents an HTTP session. +type Session struct { + ID string `json:"id"` + UserID int64 `json:"user_id"` + ExpiresAt time.Time `json:"expires_at"` + CreatedAt time.Time `json:"created_at"` +} + +// RoleAdmin and RoleUser are the built-in role IDs. +const ( + RoleAdmin = "admin" + RoleUser = "user" +) From 6769e5b25e9bcb7290970fb314ef56d71be5655c Mon Sep 17 00:00:00 2001 From: Vaayne Date: Fri, 20 Mar 2026 18:49:21 +0800 Subject: [PATCH 04/53] =?UTF-8?q?=E2=9C=85=20test:=20add=20tests=20for=20a?= =?UTF-8?q?uth=20password=20hashing=20and=20authdb=20store?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - password_test.go: hash, verify, wrong password, empty hash - authdb/store_test.go: CRUD for users, roles, identities, policies, user-agent assignments, sessions; cascade delete; interface check --- internal/auth/authdb/store.go | 8 +- internal/auth/authdb/store_test.go | 542 +++++++++++++++++++++++++++++ internal/auth/password_test.go | 58 +++ internal/auth/types.go | 26 +- 4 files changed, 615 insertions(+), 19 deletions(-) create mode 100644 internal/auth/authdb/store_test.go create mode 100644 internal/auth/password_test.go diff --git a/internal/auth/authdb/store.go b/internal/auth/authdb/store.go index 8b060780..a5aa6682 100644 --- a/internal/auth/authdb/store.go +++ b/internal/auth/authdb/store.go @@ -318,9 +318,7 @@ func (s *Store) ListUserAgentIDs(ctx context.Context, userID int64) ([]string, e return nil, fmt.Errorf("list user agents for user %d: %w", userID, err) } out := make([]string, len(rows)) - for i, r := range rows { - out[i] = r - } + copy(out, rows) return out, nil } @@ -330,9 +328,7 @@ func (s *Store) ListAgentUserIDs(ctx context.Context, agentID string) ([]int64, return nil, fmt.Errorf("list agent users for agent %q: %w", agentID, err) } out := make([]int64, len(rows)) - for i, r := range rows { - out[i] = r - } + copy(out, rows) return out, nil } diff --git a/internal/auth/authdb/store_test.go b/internal/auth/authdb/store_test.go new file mode 100644 index 00000000..4832b9ec --- /dev/null +++ b/internal/auth/authdb/store_test.go @@ -0,0 +1,542 @@ +package authdb_test + +import ( + "context" + "database/sql" + "path/filepath" + "testing" + "time" + + "github.com/vaayne/anna/internal/auth" + "github.com/vaayne/anna/internal/auth/authdb" + "github.com/vaayne/anna/internal/config" + appdb "github.com/vaayne/anna/internal/db" +) + +func setupStore(t *testing.T) (*authdb.Store, *sql.DB) { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "test.db") + db, err := appdb.OpenDB(dbPath) + if err != nil { + t.Fatalf("OpenDB: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + return authdb.New(db), db +} + +// seedAgent creates a test agent (needed for FK constraints on auth_user_agents). +func seedAgent(t *testing.T, db *sql.DB, id string) { + t.Helper() + cs := config.NewDBStore(db) + if err := cs.CreateAgent(context.Background(), config.Agent{ + ID: id, Name: id, Model: "p/m", Workspace: "/tmp/" + id, Enabled: true, + }); err != nil { + t.Fatalf("seed agent %q: %v", id, err) + } +} + +func TestUserCRUD(t *testing.T) { + t.Parallel() + store, _ := setupStore(t) + ctx := context.Background() + + // Create. + user, err := store.CreateUser(ctx, "alice", "hash123") + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + if user.Username != "alice" || !user.IsActive { + t.Errorf("CreateUser = %+v", user) + } + if user.ID == 0 { + t.Error("expected non-zero ID") + } + + // Get by ID. + got, err := store.GetUser(ctx, user.ID) + if err != nil { + t.Fatalf("GetUser: %v", err) + } + if got.Username != "alice" { + t.Errorf("GetUser username = %q", got.Username) + } + + // Get by username. + got, err = store.GetUserByUsername(ctx, "alice") + if err != nil { + t.Fatalf("GetUserByUsername: %v", err) + } + if got.ID != user.ID { + t.Errorf("GetUserByUsername ID = %d, want %d", got.ID, user.ID) + } + + // List. + users, err := store.ListUsers(ctx) + if err != nil { + t.Fatalf("ListUsers: %v", err) + } + if len(users) != 1 { + t.Errorf("ListUsers = %d users, want 1", len(users)) + } + + // Count. + count, err := store.CountUsers(ctx) + if err != nil { + t.Fatalf("CountUsers: %v", err) + } + if count != 1 { + t.Errorf("CountUsers = %d, want 1", count) + } + + // Update. + got.Username = "alice2" + got.IsActive = false + got.PasswordHash = "newhash" + if err := store.UpdateUser(ctx, got); err != nil { + t.Fatalf("UpdateUser: %v", err) + } + updated, _ := store.GetUser(ctx, user.ID) + if updated.Username != "alice2" || updated.IsActive || updated.PasswordHash != "newhash" { + t.Errorf("after update = %+v", updated) + } + + // Delete. + if err := store.DeleteUser(ctx, user.ID); err != nil { + t.Fatalf("DeleteUser: %v", err) + } + _, err = store.GetUser(ctx, user.ID) + if err == nil { + t.Error("expected error after delete") + } +} + +func TestUserDuplicateUsername(t *testing.T) { + t.Parallel() + store, _ := setupStore(t) + ctx := context.Background() + + _, err := store.CreateUser(ctx, "bob", "hash") + if err != nil { + t.Fatalf("first create: %v", err) + } + _, err = store.CreateUser(ctx, "bob", "hash2") + if err == nil { + t.Error("expected error on duplicate username") + } +} + +func TestRoleCRUD(t *testing.T) { + t.Parallel() + store, _ := setupStore(t) + ctx := context.Background() + + role, err := store.CreateRole(ctx, auth.Role{ + ID: "admin", Name: "Admin", Description: "Full access", IsSystem: true, + }) + if err != nil { + t.Fatalf("CreateRole: %v", err) + } + if role.ID != "admin" || !role.IsSystem { + t.Errorf("CreateRole = %+v", role) + } + + got, err := store.GetRole(ctx, "admin") + if err != nil { + t.Fatalf("GetRole: %v", err) + } + if got.Name != "Admin" { + t.Errorf("GetRole name = %q", got.Name) + } + + roles, err := store.ListRoles(ctx) + if err != nil { + t.Fatalf("ListRoles: %v", err) + } + if len(roles) != 1 { + t.Errorf("ListRoles = %d roles", len(roles)) + } + + got.Name = "Administrator" + got.Description = "Updated" + if err := store.UpdateRole(ctx, got); err != nil { + t.Fatalf("UpdateRole: %v", err) + } + updated, _ := store.GetRole(ctx, "admin") + if updated.Name != "Administrator" { + t.Errorf("after update name = %q", updated.Name) + } + + if err := store.DeleteRole(ctx, "admin"); err != nil { + t.Fatalf("DeleteRole: %v", err) + } + _, err = store.GetRole(ctx, "admin") + if err == nil { + t.Error("expected error after delete") + } +} + +func TestUserRoleAssignment(t *testing.T) { + t.Parallel() + store, _ := setupStore(t) + ctx := context.Background() + + user, _ := store.CreateUser(ctx, "carol", "hash") + _, _ = store.CreateRole(ctx, auth.Role{ID: "admin", Name: "Admin"}) + _, _ = store.CreateRole(ctx, auth.Role{ID: "user", Name: "User"}) + + // Assign. + if err := store.AssignRole(ctx, user.ID, "admin"); err != nil { + t.Fatalf("AssignRole: %v", err) + } + if err := store.AssignRole(ctx, user.ID, "user"); err != nil { + t.Fatalf("AssignRole user: %v", err) + } + + // Idempotent assign. + if err := store.AssignRole(ctx, user.ID, "admin"); err != nil { + t.Fatalf("duplicate AssignRole: %v", err) + } + + roles, err := store.ListUserRoles(ctx, user.ID) + if err != nil { + t.Fatalf("ListUserRoles: %v", err) + } + if len(roles) != 2 { + t.Errorf("ListUserRoles = %d roles, want 2", len(roles)) + } + + // Remove. + if err := store.RemoveRole(ctx, user.ID, "admin"); err != nil { + t.Fatalf("RemoveRole: %v", err) + } + roles, _ = store.ListUserRoles(ctx, user.ID) + if len(roles) != 1 || roles[0].ID != "user" { + t.Errorf("after remove = %+v", roles) + } +} + +func TestIdentityCRUD(t *testing.T) { + t.Parallel() + store, _ := setupStore(t) + ctx := context.Background() + + user, _ := store.CreateUser(ctx, "dave", "hash") + + identity, err := store.CreateIdentity(ctx, auth.Identity{ + UserID: user.ID, Platform: "telegram", ExternalID: "tg-123", Name: "Dave TG", + }) + if err != nil { + t.Fatalf("CreateIdentity: %v", err) + } + if identity.ID == 0 || identity.Platform != "telegram" { + t.Errorf("CreateIdentity = %+v", identity) + } + + got, err := store.GetIdentity(ctx, identity.ID) + if err != nil { + t.Fatalf("GetIdentity: %v", err) + } + if got.ExternalID != "tg-123" { + t.Errorf("GetIdentity external_id = %q", got.ExternalID) + } + + got, err = store.GetIdentityByPlatform(ctx, "telegram", "tg-123") + if err != nil { + t.Fatalf("GetIdentityByPlatform: %v", err) + } + if got.UserID != user.ID { + t.Errorf("GetIdentityByPlatform user_id = %d", got.UserID) + } + + identities, err := store.ListIdentitiesByUser(ctx, user.ID) + if err != nil { + t.Fatalf("ListIdentitiesByUser: %v", err) + } + if len(identities) != 1 { + t.Errorf("ListIdentitiesByUser = %d", len(identities)) + } + + if err := store.DeleteIdentity(ctx, identity.ID); err != nil { + t.Fatalf("DeleteIdentity: %v", err) + } + _, err = store.GetIdentity(ctx, identity.ID) + if err == nil { + t.Error("expected error after delete") + } +} + +func TestIdentityUniqueConstraint(t *testing.T) { + t.Parallel() + store, _ := setupStore(t) + ctx := context.Background() + + user, _ := store.CreateUser(ctx, "eve", "hash") + _, _ = store.CreateIdentity(ctx, auth.Identity{ + UserID: user.ID, Platform: "telegram", ExternalID: "tg-456", + }) + + _, err := store.CreateIdentity(ctx, auth.Identity{ + UserID: user.ID, Platform: "telegram", ExternalID: "tg-456", + }) + if err == nil { + t.Error("expected error on duplicate platform+external_id") + } +} + +func TestPolicyCRUD(t *testing.T) { + t.Parallel() + store, _ := setupStore(t) + ctx := context.Background() + + policy, err := store.CreatePolicy(ctx, auth.Policy{ + ID: "system:admin-full", + Name: "Admin Full Access", + Effect: auth.EffectAllow, + Subjects: `{"roles":["admin"]}`, + Actions: `["*"]`, + Resources: `["*"]`, + Priority: 100, + IsSystem: true, + Enabled: true, + }) + if err != nil { + t.Fatalf("CreatePolicy: %v", err) + } + if policy.ID != "system:admin-full" || !policy.IsSystem { + t.Errorf("CreatePolicy = %+v", policy) + } + + got, err := store.GetPolicy(ctx, "system:admin-full") + if err != nil { + t.Fatalf("GetPolicy: %v", err) + } + if got.Effect != auth.EffectAllow || got.Priority != 100 { + t.Errorf("GetPolicy = %+v", got) + } + + // Create a disabled policy. + _, _ = store.CreatePolicy(ctx, auth.Policy{ + ID: "custom:deny", Name: "Deny", Effect: auth.EffectDeny, Enabled: false, + }) + + all, _ := store.ListPolicies(ctx) + if len(all) != 2 { + t.Errorf("ListPolicies = %d, want 2", len(all)) + } + + enabled, _ := store.ListEnabledPolicies(ctx) + if len(enabled) != 1 { + t.Errorf("ListEnabledPolicies = %d, want 1", len(enabled)) + } + + // Update. + got.Name = "Admin Full Updated" + got.Enabled = false + if err := store.UpdatePolicy(ctx, got); err != nil { + t.Fatalf("UpdatePolicy: %v", err) + } + updated, _ := store.GetPolicy(ctx, "system:admin-full") + if updated.Name != "Admin Full Updated" || updated.Enabled { + t.Errorf("after update = %+v", updated) + } + + // Delete. + if err := store.DeletePolicy(ctx, "system:admin-full"); err != nil { + t.Fatalf("DeletePolicy: %v", err) + } + _, err = store.GetPolicy(ctx, "system:admin-full") + if err == nil { + t.Error("expected error after delete") + } +} + +func TestUserAgentAssignment(t *testing.T) { + t.Parallel() + store, db := setupStore(t) + ctx := context.Background() + + user, _ := store.CreateUser(ctx, "frank", "hash") + seedAgent(t, db, "agent1") + seedAgent(t, db, "agent2") + + // Assign. + if err := store.AssignAgent(ctx, user.ID, "agent1"); err != nil { + t.Fatalf("AssignAgent: %v", err) + } + if err := store.AssignAgent(ctx, user.ID, "agent2"); err != nil { + t.Fatalf("AssignAgent agent2: %v", err) + } + + // Idempotent. + if err := store.AssignAgent(ctx, user.ID, "agent1"); err != nil { + t.Fatalf("duplicate AssignAgent: %v", err) + } + + agentIDs, err := store.ListUserAgentIDs(ctx, user.ID) + if err != nil { + t.Fatalf("ListUserAgentIDs: %v", err) + } + if len(agentIDs) != 2 { + t.Errorf("ListUserAgentIDs = %v, want 2 agents", agentIDs) + } + + userIDs, err := store.ListAgentUserIDs(ctx, "agent1") + if err != nil { + t.Fatalf("ListAgentUserIDs: %v", err) + } + if len(userIDs) != 1 || userIDs[0] != user.ID { + t.Errorf("ListAgentUserIDs = %v", userIDs) + } + + // Remove. + if err := store.RemoveAgent(ctx, user.ID, "agent1"); err != nil { + t.Fatalf("RemoveAgent: %v", err) + } + agentIDs, _ = store.ListUserAgentIDs(ctx, user.ID) + if len(agentIDs) != 1 || agentIDs[0] != "agent2" { + t.Errorf("after remove = %v", agentIDs) + } +} + +func TestSessionCRUD(t *testing.T) { + t.Parallel() + store, _ := setupStore(t) + ctx := context.Background() + + user, _ := store.CreateUser(ctx, "grace", "hash") + + expires := time.Now().UTC().Add(7 * 24 * time.Hour) + sess, err := store.CreateSession(ctx, auth.Session{ + ID: "sess-abc", UserID: user.ID, ExpiresAt: expires, + }) + if err != nil { + t.Fatalf("CreateSession: %v", err) + } + if sess.ID != "sess-abc" || sess.UserID != user.ID { + t.Errorf("CreateSession = %+v", sess) + } + + got, err := store.GetSession(ctx, "sess-abc") + if err != nil { + t.Fatalf("GetSession: %v", err) + } + if got.UserID != user.ID { + t.Errorf("GetSession user_id = %d", got.UserID) + } + + // Update expiry. + newExpiry := time.Now().UTC().Add(14 * 24 * time.Hour) + if err := store.UpdateSessionExpiry(ctx, "sess-abc", newExpiry.Format("2006-01-02 15:04:05")); err != nil { + t.Fatalf("UpdateSessionExpiry: %v", err) + } + + // Delete. + if err := store.DeleteSession(ctx, "sess-abc"); err != nil { + t.Fatalf("DeleteSession: %v", err) + } + _, err = store.GetSession(ctx, "sess-abc") + if err == nil { + t.Error("expected error after delete") + } +} + +func TestDeleteExpiredSessions(t *testing.T) { + t.Parallel() + store, _ := setupStore(t) + ctx := context.Background() + + user, _ := store.CreateUser(ctx, "hank", "hash") + + // Create an expired session. + past := time.Now().UTC().Add(-1 * time.Hour) + _, _ = store.CreateSession(ctx, auth.Session{ + ID: "expired", UserID: user.ID, ExpiresAt: past, + }) + // Create a valid session. + future := time.Now().UTC().Add(1 * time.Hour) + _, _ = store.CreateSession(ctx, auth.Session{ + ID: "valid", UserID: user.ID, ExpiresAt: future, + }) + + if err := store.DeleteExpiredSessions(ctx); err != nil { + t.Fatalf("DeleteExpiredSessions: %v", err) + } + + _, err := store.GetSession(ctx, "expired") + if err == nil { + t.Error("expired session should be deleted") + } + + _, err = store.GetSession(ctx, "valid") + if err != nil { + t.Error("valid session should still exist") + } +} + +func TestDeleteUserSessions(t *testing.T) { + t.Parallel() + store, _ := setupStore(t) + ctx := context.Background() + + user, _ := store.CreateUser(ctx, "iris", "hash") + future := time.Now().UTC().Add(1 * time.Hour) + _, _ = store.CreateSession(ctx, auth.Session{ + ID: "s1", UserID: user.ID, ExpiresAt: future, + }) + _, _ = store.CreateSession(ctx, auth.Session{ + ID: "s2", UserID: user.ID, ExpiresAt: future, + }) + + if err := store.DeleteUserSessions(ctx, user.ID); err != nil { + t.Fatalf("DeleteUserSessions: %v", err) + } + + _, err := store.GetSession(ctx, "s1") + if err == nil { + t.Error("s1 should be deleted") + } + _, err = store.GetSession(ctx, "s2") + if err == nil { + t.Error("s2 should be deleted") + } +} + +func TestCascadeDeleteUser(t *testing.T) { + t.Parallel() + store, _ := setupStore(t) + ctx := context.Background() + + user, _ := store.CreateUser(ctx, "jack", "hash") + _, _ = store.CreateRole(ctx, auth.Role{ID: "user", Name: "User"}) + _ = store.AssignRole(ctx, user.ID, "user") + _, _ = store.CreateIdentity(ctx, auth.Identity{ + UserID: user.ID, Platform: "qq", ExternalID: "qq-1", + }) + future := time.Now().UTC().Add(1 * time.Hour) + _, _ = store.CreateSession(ctx, auth.Session{ + ID: "csess", UserID: user.ID, ExpiresAt: future, + }) + + // Delete user should cascade. + if err := store.DeleteUser(ctx, user.ID); err != nil { + t.Fatalf("DeleteUser: %v", err) + } + + roles, _ := store.ListUserRoles(ctx, user.ID) + if len(roles) != 0 { + t.Error("roles should be cascade deleted") + } + + identities, _ := store.ListIdentitiesByUser(ctx, user.ID) + if len(identities) != 0 { + t.Error("identities should be cascade deleted") + } + + _, err := store.GetSession(ctx, "csess") + if err == nil { + t.Error("session should be cascade deleted") + } +} + +// Verify that authdb.Store satisfies auth.AuthStore interface at compile time. +var _ auth.AuthStore = (*authdb.Store)(nil) diff --git a/internal/auth/password_test.go b/internal/auth/password_test.go new file mode 100644 index 00000000..2a233048 --- /dev/null +++ b/internal/auth/password_test.go @@ -0,0 +1,58 @@ +package auth_test + +import ( + "testing" + + "github.com/vaayne/anna/internal/auth" +) + +func TestHashPassword(t *testing.T) { + t.Parallel() + + plain := "securepassword123" + hash, err := auth.HashPassword(plain) + if err != nil { + t.Fatalf("HashPassword: %v", err) + } + if hash == "" { + t.Fatal("HashPassword returned empty hash") + } + if hash == plain { + t.Fatal("HashPassword returned plaintext") + } + + // Hashing the same password twice should produce different hashes (bcrypt salt). + hash2, err := auth.HashPassword(plain) + if err != nil { + t.Fatalf("HashPassword (second call): %v", err) + } + if hash == hash2 { + t.Error("two calls to HashPassword produced identical hashes") + } +} + +func TestCheckPassword(t *testing.T) { + t.Parallel() + + plain := "securepassword123" + hash, err := auth.HashPassword(plain) + if err != nil { + t.Fatalf("HashPassword: %v", err) + } + + if err := auth.CheckPassword(hash, plain); err != nil { + t.Errorf("CheckPassword with correct password: %v", err) + } + + if err := auth.CheckPassword(hash, "wrongpassword"); err == nil { + t.Error("CheckPassword with wrong password should return error") + } +} + +func TestCheckPasswordEmptyHash(t *testing.T) { + t.Parallel() + + if err := auth.CheckPassword("", "anything"); err == nil { + t.Error("CheckPassword with empty hash should return error") + } +} diff --git a/internal/auth/types.go b/internal/auth/types.go index 2c5c9b19..8fde98e4 100644 --- a/internal/auth/types.go +++ b/internal/auth/types.go @@ -23,16 +23,16 @@ type Role struct { // Policy represents an ABAC policy with JSON conditions. type Policy struct { - ID string `json:"id"` - Name string `json:"name"` - Effect string `json:"effect"` - Subjects string `json:"subjects"` - Actions string `json:"actions"` - Resources string `json:"resources"` - Conditions string `json:"conditions"` - Priority int `json:"priority"` - IsSystem bool `json:"is_system"` - Enabled bool `json:"enabled"` + ID string `json:"id"` + Name string `json:"name"` + Effect string `json:"effect"` + Subjects string `json:"subjects"` + Actions string `json:"actions"` + Resources string `json:"resources"` + Conditions string `json:"conditions"` + Priority int `json:"priority"` + IsSystem bool `json:"is_system"` + Enabled bool `json:"enabled"` CreatedAt time.Time `json:"created_at"` } @@ -44,9 +44,9 @@ const ( // AccessRequest represents a request to check authorization. type AccessRequest struct { - Subject Subject `json:"subject"` - Action Action `json:"action"` - Resource Resource `json:"resource"` + Subject Subject `json:"subject"` + Action Action `json:"action"` + Resource Resource `json:"resource"` Context map[string]any `json:"context,omitempty"` } From 3293372fc622a3f74abe1ef599faede3e62a627f Mon Sep 17 00:00:00 2001 From: Vaayne Date: Fri, 20 Mar 2026 18:50:40 +0800 Subject: [PATCH 05/53] =?UTF-8?q?=F0=9F=93=9D=20docs:=20update=20Phase=201?= =?UTF-8?q?=20task=20checklist=20and=20handoff=20log?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/sessions/2026-03-20-rbac/handoff.md | 55 ++++++++++++++ .agents/sessions/2026-03-20-rbac/tasks.md | 80 +++++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 .agents/sessions/2026-03-20-rbac/handoff.md create mode 100644 .agents/sessions/2026-03-20-rbac/tasks.md diff --git a/.agents/sessions/2026-03-20-rbac/handoff.md b/.agents/sessions/2026-03-20-rbac/handoff.md new file mode 100644 index 00000000..011ae6b8 --- /dev/null +++ b/.agents/sessions/2026-03-20-rbac/handoff.md @@ -0,0 +1,55 @@ +# Handoff + + + +## Phase 1: Auth Foundation — DB Schema + Auth Package + +**Status:** Complete +**Date:** 2026-03-20 +**Commits:** 4 commits on `main` + +### What was done + +1. **Schema files** (7 new + 1 modified): + - `internal/db/schemas/tables/auth_users.sql` — system users with username, password_hash, is_active + - `internal/db/schemas/tables/auth_roles.sql` — extensible roles (TEXT PK) + - `internal/db/schemas/tables/auth_user_roles.sql` — user-role many-to-many with CASCADE + - `internal/db/schemas/tables/auth_identities.sql` — linked channel identities with UNIQUE(platform, external_id) + - `internal/db/schemas/tables/auth_policies.sql` — ABAC policies with CHECK(effect IN ('allow','deny')) + - `internal/db/schemas/tables/auth_user_agents.sql` — user-agent binary access with CASCADE + - `internal/db/schemas/tables/auth_sessions.sql` — HTTP sessions with CASCADE + - `internal/db/schemas/tables/settings_agents.sql` — added `scope TEXT NOT NULL DEFAULT 'system'` + - `internal/db/schemas/main.sql` — added atlas:import for all 7 auth tables + +2. **Migration**: `internal/db/migrations/20260320104110_add-auth-tables.sql` + - ALTER TABLE settings_agents ADD scope column + - CREATE TABLE for all 7 auth tables with FKs, unique indices, and check constraints + - `atlas.sum` updated and validated + +3. **sqlc queries** (7 new files in `internal/db/queries/`): + - `auth_users.sql` — CRUD + get by username + count + - `auth_roles.sql` — CRUD + list + - `auth_user_roles.sql` — assign (ON CONFLICT DO NOTHING) / remove / list roles for user / list users for role + - `auth_identities.sql` — CRUD + get by platform+external_id + list by user + - `auth_policies.sql` — CRUD + list enabled + - `auth_user_agents.sql` — assign / remove / list agents for user / list users for agent + - `auth_sessions.sql` — create / get / delete / delete expired / delete by user / update expiry + +4. **Auth package** (`internal/auth/`): + - `types.go` — AuthUser, Role, Policy, AccessRequest, Subject, Action (with 6 constants), Resource (with 10 ResourceType constants), Identity, Session, effect constants, role constants + - `password.go` — HashPassword (bcrypt cost=12), CheckPassword + - `store.go` — AuthStore interface with 26 methods (users, roles, identities, policies, user-agents, sessions) + +5. **AuthStore implementation** (`internal/auth/authdb/`): + - `store.go` — Full SQLite implementation using sqlc Queries, with time parsing helpers and DB-to-domain converters + +6. **Tests**: + - `internal/auth/password_test.go` — hash, verify, wrong password, empty hash, salt uniqueness + - `internal/auth/authdb/store_test.go` — CRUD for all 6 entity types, idempotent assigns, unique constraints, cascade delete, interface satisfaction check + - All tests pass with `-race` + +### Notes for next phases + +- **`ctx_agent_memory.user_id` FK**: Currently references `settings_users(id)`. This needs attention when handling data migration (not in scope for Phase 1). The FK target will need to change to `auth_users(id)` with a data migration step. +- **`settings_agents.scope`**: Column added to schema and migration. The existing sqlc-generated code now includes `Scope` in `SettingsAgent` model, but existing queries (CreateAgent, UpdateAgent) do not set it — it defaults to `'system'`. The `config.Agent` struct and `agentFromDB` helper do NOT yet map the scope field (deferred to Phase 5 task 5.1). +- **Pre-existing test failures**: Integration tests in `internal/agent/` and `internal/agent/runner/` fail due to missing API keys — these are not related to this phase's changes. diff --git a/.agents/sessions/2026-03-20-rbac/tasks.md b/.agents/sessions/2026-03-20-rbac/tasks.md new file mode 100644 index 00000000..0b9eee78 --- /dev/null +++ b/.agents/sessions/2026-03-20-rbac/tasks.md @@ -0,0 +1,80 @@ +# Tasks: RBAC + ABAC Permission System + +## Phase 1: Auth Foundation — DB Schema + Auth Package + +- [x] 1.1 — Create auth DB schema files (`internal/db/schemas/tables/auth_users.sql`, `auth_roles.sql`, `auth_user_roles.sql`, `auth_identities.sql`, `auth_policies.sql`, `auth_user_agents.sql`, `auth_sessions.sql`) +- [x] 1.2 — Add `scope` column to `settings_agents` table (`internal/db/schemas/tables/settings_agents.sql`) +- [x] 1.3 — Generate Atlas migration (`mise run atlas:diff -- add-auth-tables`) +- [x] 1.4 — Create sqlc queries for all auth tables (`internal/db/queries/auth_*.sql`) +- [x] 1.5 — Run `mise run generate` to regenerate sqlc +- [x] 1.6 — Create `internal/auth/types.go` +- [x] 1.7 — Create `internal/auth/password.go` +- [x] 1.8 — Create `internal/auth/store.go` (AuthStore interface) +- [x] 1.9 — Create `internal/auth/authdb/store.go` (SQLite implementation) +- [x] 1.10 — Write tests for password and authdb store + +## Phase 2: Policy Engine + +- [ ] 2.1 — Create `internal/auth/engine.go` (PolicyEngine, Can, Must) +- [ ] 2.2 — Implement condition evaluator (JSON conditions, operators: eq, neq, in, not_in, contains) +- [ ] 2.3 — Implement deny-overrides algorithm +- [ ] 2.4 — Create `internal/auth/seed.go` (8 built-in policies + 2 roles) +- [ ] 2.5 — Integrate seed into bootstrap +- [ ] 2.6 — Write tests (policy matching, conditions, deny-overrides, edge cases) + +## Phase 3: Admin UI Authentication + +- [ ] 3.1 — Create `internal/auth/session.go` (crypto/rand IDs, cookies, lazy cleanup) +- [ ] 3.2 — Create `internal/auth/ratelimit.go` (per-IP + per-username throttling) +- [ ] 3.3 — Create login/register templ page (`internal/admin/ui/pages/login.templ`) +- [ ] 3.4 — Create login page JS (`internal/admin/ui/static/js/pages/login.js`) +- [ ] 3.5 — Add auth API handlers (`internal/admin/auth.go`) +- [ ] 3.6 — Add auth middleware (`internal/admin/middleware.go`) +- [ ] 3.7 — Harden CORS in `server.go` +- [ ] 3.8 — Apply auth middleware to routes, exempt login/static/auth +- [ ] 3.9 — Add admin-only route guard middleware +- [ ] 3.10 — Modify navbar for role-based visibility +- [ ] 3.11 — Modify root redirect (unauthenticated → login) +- [ ] 3.12 — Write tests + +## Phase 4: User Profile + Channel Linking + +- [ ] 4.1 — Create `internal/auth/linkcode.go` (in-memory sync.Map, 5-min TTL) +- [ ] 4.2 — Create profile templ page (`internal/admin/ui/pages/profile.templ`) +- [ ] 4.3 — Create profile page JS (`internal/admin/ui/static/js/pages/profile.js`) +- [ ] 4.4 — Add profile API handlers (get profile, update password, link code, identities) +- [ ] 4.5 — Add route + nav link for `/profile` +- [ ] 4.6 — Modify channel handlers to intercept link-code messages +- [ ] 4.7 — Modify `identity.go`: resolve via auth_identities with auto-migration fallback +- [ ] 4.8 — Write tests + +## Phase 5: Agent Scoping + Access Enforcement + +- [ ] 5.1 — Add `scope` field to `config.Agent` struct and agent form in admin UI +- [ ] 5.2 — Create user-agent assignment API (`POST/DELETE /api/agents/{id}/users/{userId}`) +- [ ] 5.3 — Create admin UI for agent user management +- [ ] 5.4 — Integrate policy engine into admin API middleware +- [ ] 5.5 — Integrate policy engine into channel identity resolution +- [ ] 5.6 — Return permission denied / link prompt on channel access failures +- [ ] 5.7 — Modify `ResolveAgent()` to filter by accessible agents +- [ ] 5.8 — Write tests + +## Phase 6: Per-User Data + Skills Isolation + +- [ ] 6.1 — Modify `SetupWorkspace()` for per-user directories +- [ ] 6.2 — Modify `SkillsTool` to accept `userID`, per-user skill path +- [ ] 6.3 — Modify skill load/list/install/remove for per-user paths +- [ ] 6.4 — Modify `LoadSkills()` in `runner/skill.go`: add user dir in priority chain +- [ ] 6.5 — Modify runner creation to pass user ID +- [ ] 6.6 — Add sandbox enforcement (`internal/auth/sandbox.go`, file tool integration) +- [ ] 6.7 — Migrate existing agent-level skills as shared +- [ ] 6.8 — Write tests + +## Phase 7: Admin User Management + +- [ ] 7.1 — Enhance `/users` page to show auth_users, roles, linked identities +- [ ] 7.2 — Add role management (admin promote/demote) +- [ ] 7.3 — Add agent assignment management +- [ ] 7.4 — Add user detail view (sessions, skills, identities) +- [ ] 7.5 — Cleanup old settings_users page functionality +- [ ] 7.6 — Write tests From 143461431b6af2191eb9ab3e42d58e236f5379e6 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Fri, 20 Mar 2026 18:55:57 +0800 Subject: [PATCH 06/53] =?UTF-8?q?=F0=9F=90=9B=20fix:=20improve=20auth=20st?= =?UTF-8?q?ore=20error=20wrapping=20and=20type=20safety?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change UpdateSessionExpiry to accept time.Time instead of string - Wrap all error returns with fmt.Errorf context - Log parseTime failures instead of silently discarding --- internal/auth/authdb/store.go | 101 +++++++++++++++++++++-------- internal/auth/authdb/store_test.go | 2 +- internal/auth/store.go | 7 +- 3 files changed, 81 insertions(+), 29 deletions(-) diff --git a/internal/auth/authdb/store.go b/internal/auth/authdb/store.go index a5aa6682..4e3439de 100644 --- a/internal/auth/authdb/store.go +++ b/internal/auth/authdb/store.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "fmt" + "log/slog" "time" "github.com/vaayne/anna/internal/auth" @@ -68,16 +69,22 @@ func (s *Store) UpdateUser(ctx context.Context, u auth.AuthUser) error { if u.IsActive { isActive = 1 } - return s.q.UpdateAuthUser(ctx, sqlc.UpdateAuthUserParams{ + if err := s.q.UpdateAuthUser(ctx, sqlc.UpdateAuthUserParams{ ID: u.ID, Username: u.Username, PasswordHash: u.PasswordHash, IsActive: isActive, - }) + }); err != nil { + return fmt.Errorf("update auth user %d: %w", u.ID, err) + } + return nil } func (s *Store) DeleteUser(ctx context.Context, id int64) error { - return s.q.DeleteAuthUser(ctx, id) + if err := s.q.DeleteAuthUser(ctx, id); err != nil { + return fmt.Errorf("delete auth user %d: %w", id, err) + } + return nil } func (s *Store) CountUsers(ctx context.Context) (int64, error) { @@ -124,31 +131,43 @@ func (s *Store) ListRoles(ctx context.Context) ([]auth.Role, error) { } func (s *Store) UpdateRole(ctx context.Context, r auth.Role) error { - return s.q.UpdateAuthRole(ctx, sqlc.UpdateAuthRoleParams{ + if err := s.q.UpdateAuthRole(ctx, sqlc.UpdateAuthRoleParams{ ID: r.ID, Name: r.Name, Description: r.Description, - }) + }); err != nil { + return fmt.Errorf("update auth role %q: %w", r.ID, err) + } + return nil } func (s *Store) DeleteRole(ctx context.Context, id string) error { - return s.q.DeleteAuthRole(ctx, id) + if err := s.q.DeleteAuthRole(ctx, id); err != nil { + return fmt.Errorf("delete auth role %q: %w", id, err) + } + return nil } // --- User-Role assignments --- func (s *Store) AssignRole(ctx context.Context, userID int64, roleID string) error { - return s.q.AssignUserRole(ctx, sqlc.AssignUserRoleParams{ + if err := s.q.AssignUserRole(ctx, sqlc.AssignUserRoleParams{ UserID: userID, RoleID: roleID, - }) + }); err != nil { + return fmt.Errorf("assign role %q to user %d: %w", roleID, userID, err) + } + return nil } func (s *Store) RemoveRole(ctx context.Context, userID int64, roleID string) error { - return s.q.RemoveUserRole(ctx, sqlc.RemoveUserRoleParams{ + if err := s.q.RemoveUserRole(ctx, sqlc.RemoveUserRoleParams{ UserID: userID, RoleID: roleID, - }) + }); err != nil { + return fmt.Errorf("remove role %q from user %d: %w", roleID, userID, err) + } + return nil } func (s *Store) ListUserRoles(ctx context.Context, userID int64) ([]auth.Role, error) { @@ -210,7 +229,10 @@ func (s *Store) ListIdentitiesByUser(ctx context.Context, userID int64) ([]auth. } func (s *Store) DeleteIdentity(ctx context.Context, id int64) error { - return s.q.DeleteAuthIdentity(ctx, id) + if err := s.q.DeleteAuthIdentity(ctx, id); err != nil { + return fmt.Errorf("delete identity %d: %w", id, err) + } + return nil } // --- Policies --- @@ -279,7 +301,7 @@ func (s *Store) UpdatePolicy(ctx context.Context, p auth.Policy) error { if p.Enabled { enabled = 1 } - return s.q.UpdateAuthPolicy(ctx, sqlc.UpdateAuthPolicyParams{ + if err := s.q.UpdateAuthPolicy(ctx, sqlc.UpdateAuthPolicyParams{ ID: p.ID, Name: p.Name, Effect: p.Effect, @@ -289,27 +311,39 @@ func (s *Store) UpdatePolicy(ctx context.Context, p auth.Policy) error { Conditions: p.Conditions, Priority: int64(p.Priority), Enabled: enabled, - }) + }); err != nil { + return fmt.Errorf("update policy %q: %w", p.ID, err) + } + return nil } func (s *Store) DeletePolicy(ctx context.Context, id string) error { - return s.q.DeleteAuthPolicy(ctx, id) + if err := s.q.DeleteAuthPolicy(ctx, id); err != nil { + return fmt.Errorf("delete policy %q: %w", id, err) + } + return nil } // --- User-Agent assignments --- func (s *Store) AssignAgent(ctx context.Context, userID int64, agentID string) error { - return s.q.AssignUserAgent(ctx, sqlc.AssignUserAgentParams{ + if err := s.q.AssignUserAgent(ctx, sqlc.AssignUserAgentParams{ UserID: userID, AgentID: agentID, - }) + }); err != nil { + return fmt.Errorf("assign agent %q to user %d: %w", agentID, userID, err) + } + return nil } func (s *Store) RemoveAgent(ctx context.Context, userID int64, agentID string) error { - return s.q.RemoveUserAgent(ctx, sqlc.RemoveUserAgentParams{ + if err := s.q.RemoveUserAgent(ctx, sqlc.RemoveUserAgentParams{ UserID: userID, AgentID: agentID, - }) + }); err != nil { + return fmt.Errorf("remove agent %q from user %d: %w", agentID, userID, err) + } + return nil } func (s *Store) ListUserAgentIDs(ctx context.Context, userID int64) ([]string, error) { @@ -355,28 +389,43 @@ func (s *Store) GetSession(ctx context.Context, id string) (auth.Session, error) } func (s *Store) DeleteSession(ctx context.Context, id string) error { - return s.q.DeleteAuthSession(ctx, id) + if err := s.q.DeleteAuthSession(ctx, id); err != nil { + return fmt.Errorf("delete session %q: %w", id, err) + } + return nil } func (s *Store) DeleteExpiredSessions(ctx context.Context) error { - return s.q.DeleteExpiredAuthSessions(ctx) + if err := s.q.DeleteExpiredAuthSessions(ctx); err != nil { + return fmt.Errorf("delete expired sessions: %w", err) + } + return nil } func (s *Store) DeleteUserSessions(ctx context.Context, userID int64) error { - return s.q.DeleteUserAuthSessions(ctx, userID) + if err := s.q.DeleteUserAuthSessions(ctx, userID); err != nil { + return fmt.Errorf("delete sessions for user %d: %w", userID, err) + } + return nil } -func (s *Store) UpdateSessionExpiry(ctx context.Context, id string, expiresAt string) error { - return s.q.UpdateAuthSessionExpiry(ctx, sqlc.UpdateAuthSessionExpiryParams{ +func (s *Store) UpdateSessionExpiry(ctx context.Context, id string, expiresAt time.Time) error { + if err := s.q.UpdateAuthSessionExpiry(ctx, sqlc.UpdateAuthSessionExpiryParams{ ID: id, - ExpiresAt: expiresAt, - }) + ExpiresAt: expiresAt.UTC().Format(timeLayout), + }); err != nil { + return fmt.Errorf("update session expiry %q: %w", id, err) + } + return nil } // --- Helpers --- func parseTime(s string) time.Time { - t, _ := time.Parse(timeLayout, s) + t, err := time.Parse(timeLayout, s) + if err != nil { + slog.Warn("authdb: failed to parse time", "value", s, "error", err) + } return t } diff --git a/internal/auth/authdb/store_test.go b/internal/auth/authdb/store_test.go index 4832b9ec..5b88cf70 100644 --- a/internal/auth/authdb/store_test.go +++ b/internal/auth/authdb/store_test.go @@ -426,7 +426,7 @@ func TestSessionCRUD(t *testing.T) { // Update expiry. newExpiry := time.Now().UTC().Add(14 * 24 * time.Hour) - if err := store.UpdateSessionExpiry(ctx, "sess-abc", newExpiry.Format("2006-01-02 15:04:05")); err != nil { + if err := store.UpdateSessionExpiry(ctx, "sess-abc", newExpiry); err != nil { t.Fatalf("UpdateSessionExpiry: %v", err) } diff --git a/internal/auth/store.go b/internal/auth/store.go index 5b3f4262..bad5cae1 100644 --- a/internal/auth/store.go +++ b/internal/auth/store.go @@ -1,6 +1,9 @@ package auth -import "context" +import ( + "context" + "time" +) // AuthStore provides typed access to auth-related data in the database. // This is separate from config.Store — auth methods are NOT mixed in. @@ -53,5 +56,5 @@ type AuthStore interface { DeleteSession(ctx context.Context, id string) error DeleteExpiredSessions(ctx context.Context) error DeleteUserSessions(ctx context.Context, userID int64) error - UpdateSessionExpiry(ctx context.Context, id string, expiresAt string) error + UpdateSessionExpiry(ctx context.Context, id string, expiresAt time.Time) error } From c988b23e86008a43f1b649c5f373642b221313ea Mon Sep 17 00:00:00 2001 From: Vaayne Date: Fri, 20 Mar 2026 19:01:41 +0800 Subject: [PATCH 07/53] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20policy=20engine?= =?UTF-8?q?=20with=20deny-overrides=20and=20condition=20evaluator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the ABAC policy evaluation engine for Phase 2: - PolicyEngine loads enabled policies sorted by priority - Can/Must methods evaluate access requests - Deny-overrides algorithm: any deny wins, at least one allow needed - Condition evaluator supports eq, neq, in, not_in, contains operators - Attribute references (subject.id, resource.owner_id, etc.) --- internal/auth/condition.go | 238 +++++++++++++++++++++++++++++++++++++ internal/auth/engine.go | 199 +++++++++++++++++++++++++++++++ 2 files changed, 437 insertions(+) create mode 100644 internal/auth/condition.go create mode 100644 internal/auth/engine.go diff --git a/internal/auth/condition.go b/internal/auth/condition.go new file mode 100644 index 00000000..35cbf73c --- /dev/null +++ b/internal/auth/condition.go @@ -0,0 +1,238 @@ +package auth + +import ( + "encoding/json" + "fmt" + "log/slog" + "strconv" + "strings" +) + +// Condition operators. +const ( + OpEq = "eq" + OpNeq = "neq" + OpIn = "in" + OpNotIn = "not_in" + OpContains = "contains" +) + +// evaluateConditions parses the policy's conditions JSON and evaluates all +// conditions against the request. All conditions must be satisfied (AND). +// +// Conditions format: +// +// {"resource.owner_id": {"eq": "subject.id"}, "resource.scope": {"eq": "system"}} +// +// Values can be attribute references (prefixed with "subject." or "resource.") +// or literal strings/numbers. +func evaluateConditions(conditionsJSON string, req AccessRequest) bool { + if conditionsJSON == "" || conditionsJSON == "{}" { + return true + } + + var conditions map[string]map[string]any + if err := json.Unmarshal([]byte(conditionsJSON), &conditions); err != nil { + slog.Warn("policy engine: invalid conditions JSON", "json", conditionsJSON, "error", err) + return false + } + + if len(conditions) == 0 { + return true + } + + for attrPath, ops := range conditions { + leftVal := resolveAttribute(attrPath, req) + for op, rightRaw := range ops { + if !evalOp(op, leftVal, rightRaw, req) { + return false + } + } + } + + return true +} + +// resolveAttribute resolves an attribute path (e.g., "subject.id", +// "resource.owner_id", "resource.scope") to its string value from the +// request. +func resolveAttribute(path string, req AccessRequest) string { + parts := strings.SplitN(path, ".", 2) + if len(parts) != 2 { + return "" + } + + prefix, field := parts[0], parts[1] + + switch prefix { + case "subject": + return resolveSubjectAttr(field, req.Subject) + case "resource": + return resolveResourceAttr(field, req.Resource) + default: + return "" + } +} + +// resolveSubjectAttr returns a string value for a subject attribute. +func resolveSubjectAttr(field string, s Subject) string { + switch field { + case "id": + return strconv.FormatInt(s.UserID, 10) + case "roles": + // Return as JSON array for collection operators. + b, _ := json.Marshal(s.Roles) + return string(b) + case "agent_ids": + b, _ := json.Marshal(s.AgentIDs) + return string(b) + default: + if s.Attrs != nil { + return anyToString(s.Attrs[field]) + } + return "" + } +} + +// resolveResourceAttr returns a string value for a resource attribute. +func resolveResourceAttr(field string, r Resource) string { + switch field { + case "type": + return string(r.Type) + case "id": + return r.ID + case "owner_id": + return strconv.FormatInt(r.OwnerID, 10) + default: + if r.Attrs != nil { + return anyToString(r.Attrs[field]) + } + return "" + } +} + +// evalOp evaluates a single operator against left value and right operand. +// The right operand may be an attribute reference string or a literal. +func evalOp(op, leftVal string, rightRaw any, req AccessRequest) bool { + switch op { + case OpEq: + rightVal := resolveOperand(rightRaw, req) + return leftVal == rightVal + + case OpNeq: + rightVal := resolveOperand(rightRaw, req) + return leftVal != rightVal + + case OpIn: + // leftVal should be a single value; right should resolve to a + // collection (JSON array string or attribute reference to a list). + collection := resolveCollection(rightRaw, req) + for _, item := range collection { + if leftVal == item { + return true + } + } + return false + + case OpNotIn: + collection := resolveCollection(rightRaw, req) + for _, item := range collection { + if leftVal == item { + return false + } + } + return true + + case OpContains: + // leftVal should resolve to a collection; right is a single value. + leftCollection := resolveCollection(leftVal, req) + rightVal := resolveOperand(rightRaw, req) + for _, item := range leftCollection { + if item == rightVal { + return true + } + } + return false + + default: + slog.Warn("policy engine: unknown operator", "op", op) + return false + } +} + +// resolveOperand resolves the right-hand side of an operator. If it is a +// string starting with "subject." or "resource.", it is treated as an +// attribute reference. Otherwise it is treated as a literal. +func resolveOperand(raw any, req AccessRequest) string { + s, ok := raw.(string) + if ok && isAttrRef(s) { + return resolveAttribute(s, req) + } + return anyToString(raw) +} + +// resolveCollection resolves an operand to a string slice. It handles: +// - attribute reference strings (e.g., "subject.agent_ids") that resolve +// to a JSON array +// - JSON array strings (e.g., '["a","b"]') +// - plain any values that are already slices +func resolveCollection(raw any, req AccessRequest) []string { + // If raw is a string, check if it's an attribute ref first. + if s, ok := raw.(string); ok { + resolved := s + if isAttrRef(s) { + resolved = resolveAttribute(s, req) + } + // Try to parse as JSON array. + var arr []any + if json.Unmarshal([]byte(resolved), &arr) == nil { + result := make([]string, len(arr)) + for i, v := range arr { + result[i] = anyToString(v) + } + return result + } + // Single value. + return []string{resolved} + } + + // If raw is already a slice (from JSON unmarshal). + if arr, ok := raw.([]any); ok { + result := make([]string, len(arr)) + for i, v := range arr { + result[i] = anyToString(v) + } + return result + } + + return nil +} + +// isAttrRef returns true if the string looks like an attribute reference. +func isAttrRef(s string) bool { + return strings.HasPrefix(s, "subject.") || strings.HasPrefix(s, "resource.") +} + +// anyToString converts an arbitrary value to its string representation. +func anyToString(v any) string { + if v == nil { + return "" + } + switch val := v.(type) { + case string: + return val + case float64: + if val == float64(int64(val)) { + return strconv.FormatInt(int64(val), 10) + } + return strconv.FormatFloat(val, 'f', -1, 64) + case int64: + return strconv.FormatInt(val, 10) + case int: + return strconv.Itoa(val) + case bool: + return strconv.FormatBool(val) + default: + return fmt.Sprintf("%v", val) + } +} diff --git a/internal/auth/engine.go b/internal/auth/engine.go new file mode 100644 index 00000000..30e90ca0 --- /dev/null +++ b/internal/auth/engine.go @@ -0,0 +1,199 @@ +package auth + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "sort" +) + +// ErrAccessDenied is returned by Must when the request is denied. +var ErrAccessDenied = errors.New("access denied") + +// PolicyEngine evaluates access requests against loaded policies using +// a deny-overrides algorithm. Policies are loaded once at startup. +type PolicyEngine struct { + policies []Policy +} + +// NewEngine creates a PolicyEngine by loading all enabled policies from +// the store. Policies are sorted by priority (descending) for deterministic +// evaluation. +func NewEngine(ctx context.Context, store AuthStore) (*PolicyEngine, error) { + policies, err := store.ListEnabledPolicies(ctx) + if err != nil { + return nil, fmt.Errorf("auth engine: load policies: %w", err) + } + + sort.Slice(policies, func(i, j int) bool { + if policies[i].Priority != policies[j].Priority { + return policies[i].Priority > policies[j].Priority + } + return policies[i].ID < policies[j].ID + }) + + return &PolicyEngine{policies: policies}, nil +} + +// NewEngineFromPolicies creates a PolicyEngine from a pre-loaded set of +// policies. Useful for testing. +func NewEngineFromPolicies(policies []Policy) *PolicyEngine { + sorted := make([]Policy, len(policies)) + copy(sorted, policies) + sort.Slice(sorted, func(i, j int) bool { + if sorted[i].Priority != sorted[j].Priority { + return sorted[i].Priority > sorted[j].Priority + } + return sorted[i].ID < sorted[j].ID + }) + return &PolicyEngine{policies: sorted} +} + +// Can returns true if the access request is allowed by the loaded policies. +// It uses the deny-overrides algorithm: +// 1. Find all policies matching subject roles, action, and resource type +// 2. Evaluate conditions (ABAC) for matching policies +// 3. If ANY matching policy has effect=deny -> deny +// 4. If at least one matching policy has effect=allow -> allow +// 5. No match -> deny (default deny) +func (e *PolicyEngine) Can(_ context.Context, req AccessRequest) bool { + var hasAllow bool + + for _, p := range e.policies { + if !e.matchesPolicy(p, req) { + continue + } + + if !evaluateConditions(p.Conditions, req) { + continue + } + + if p.Effect == EffectDeny { + slog.Debug("policy engine: deny", + "policy", p.ID, "subject", req.Subject.UserID, + "action", req.Action, "resource", req.Resource.Type) + return false + } + + if p.Effect == EffectAllow { + hasAllow = true + } + } + + if hasAllow { + return true + } + + slog.Debug("policy engine: default deny (no matching allow)", + "subject", req.Subject.UserID, + "action", req.Action, "resource", req.Resource.Type) + return false +} + +// Must is like Can but returns ErrAccessDenied when the request is denied. +func (e *PolicyEngine) Must(ctx context.Context, req AccessRequest) error { + if e.Can(ctx, req) { + return nil + } + return ErrAccessDenied +} + +// matchesPolicy checks whether a policy's subjects, actions, and resources +// match the given request (before condition evaluation). +func (e *PolicyEngine) matchesPolicy(p Policy, req AccessRequest) bool { + return matchSubjects(p.Subjects, req.Subject) && + matchActions(p.Actions, req.Action) && + matchResources(p.Resources, req.Resource) +} + +// policySubjects is the JSON structure of a policy's subjects field. +type policySubjects struct { + Roles []string `json:"roles"` +} + +// matchSubjects checks if the policy's subject roles overlap with the +// request subject's roles. Wildcard "*" matches any role. +func matchSubjects(subjectsJSON string, subject Subject) bool { + if subjectsJSON == "" || subjectsJSON == "{}" { + return true + } + + var ps policySubjects + if err := json.Unmarshal([]byte(subjectsJSON), &ps); err != nil { + slog.Warn("policy engine: invalid subjects JSON", "json", subjectsJSON, "error", err) + return false + } + + if len(ps.Roles) == 0 { + return true + } + + for _, pr := range ps.Roles { + if pr == "*" { + return true + } + for _, sr := range subject.Roles { + if pr == sr { + return true + } + } + } + + return false +} + +// matchActions checks if the policy's actions contain the request action. +// Wildcard "*" matches any action. +func matchActions(actionsJSON string, action Action) bool { + if actionsJSON == "" || actionsJSON == "[]" { + return true + } + + var actions []string + if err := json.Unmarshal([]byte(actionsJSON), &actions); err != nil { + slog.Warn("policy engine: invalid actions JSON", "json", actionsJSON, "error", err) + return false + } + + if len(actions) == 0 { + return true + } + + actionStr := string(action) + for _, a := range actions { + if a == "*" || a == actionStr { + return true + } + } + + return false +} + +// matchResources checks if the policy's resources contain the request +// resource type. Wildcard "*" matches any resource type. +func matchResources(resourcesJSON string, resource Resource) bool { + if resourcesJSON == "" || resourcesJSON == "[]" { + return true + } + + var resources []string + if err := json.Unmarshal([]byte(resourcesJSON), &resources); err != nil { + slog.Warn("policy engine: invalid resources JSON", "json", resourcesJSON, "error", err) + return false + } + + if len(resources) == 0 { + return true + } + + resType := string(resource.Type) + for _, r := range resources { + if r == "*" || r == resType { + return true + } + } + + return false +} From 7fbd9842992e53137d919375357950f85279501f Mon Sep 17 00:00:00 2001 From: Vaayne Date: Fri, 20 Mar 2026 19:01:47 +0800 Subject: [PATCH 08/53] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20auth=20seed=20f?= =?UTF-8?q?or=20built-in=20roles=20and=20policies?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seeds 2 system roles (admin, user) and 8 built-in ABAC policies on bootstrap. Uses idempotent pattern — existing entries are skipped. --- internal/auth/seed.go | 186 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 internal/auth/seed.go diff --git a/internal/auth/seed.go b/internal/auth/seed.go new file mode 100644 index 00000000..2a0af059 --- /dev/null +++ b/internal/auth/seed.go @@ -0,0 +1,186 @@ +package auth + +import ( + "context" + "database/sql" + "errors" + "fmt" + "log/slog" +) + +// builtinRoles defines the system roles seeded on bootstrap. +var builtinRoles = []Role{ + { + ID: RoleAdmin, + Name: "Admin", + Description: "Full system access", + IsSystem: true, + }, + { + ID: RoleUser, + Name: "User", + Description: "Standard user with scoped access", + IsSystem: true, + }, +} + +// builtinPolicies defines the system policies seeded on bootstrap. +var builtinPolicies = []Policy{ + { + ID: "system:admin-full-access", + Name: "Admin Full Access", + Effect: EffectAllow, + Subjects: `{"roles":["admin"]}`, + Actions: `["*"]`, + Resources: `["*"]`, + Conditions: `{}`, + Priority: 100, + IsSystem: true, + Enabled: true, + }, + { + ID: "system:user-system-agents", + Name: "User System Agents", + Effect: EffectAllow, + Subjects: `{"roles":["user"]}`, + Actions: `["read","execute"]`, + Resources: `["agent"]`, + Conditions: `{"resource.scope":{"eq":"system"}}`, + Priority: 50, + IsSystem: true, + Enabled: true, + }, + { + ID: "system:user-assigned-agents", + Name: "User Assigned Agents", + Effect: EffectAllow, + Subjects: `{"roles":["user"]}`, + Actions: `["read","execute"]`, + Resources: `["agent"]`, + Conditions: `{"resource.id":{"in":"subject.agent_ids"}}`, + Priority: 50, + IsSystem: true, + Enabled: true, + }, + { + ID: "system:user-own-sessions", + Name: "User Own Sessions", + Effect: EffectAllow, + Subjects: `{"roles":["user"]}`, + Actions: `["read","write","create","delete"]`, + Resources: `["session"]`, + Conditions: `{"resource.owner_id":{"eq":"subject.id"}}`, + Priority: 50, + IsSystem: true, + Enabled: true, + }, + { + ID: "system:user-own-data", + Name: "User Own Data", + Effect: EffectAllow, + Subjects: `{"roles":["user"]}`, + Actions: `["read","write"]`, + Resources: `["user_data"]`, + Conditions: `{"resource.owner_id":{"eq":"subject.id"}}`, + Priority: 50, + IsSystem: true, + Enabled: true, + }, + { + ID: "system:user-own-skills", + Name: "User Own Skills", + Effect: EffectAllow, + Subjects: `{"roles":["user"]}`, + Actions: `["read","write","create","delete"]`, + Resources: `["skill"]`, + Conditions: `{"resource.owner_id":{"eq":"subject.id"}}`, + Priority: 50, + IsSystem: true, + Enabled: true, + }, + { + ID: "system:user-own-profile", + Name: "User Own Profile", + Effect: EffectAllow, + Subjects: `{"roles":["user"]}`, + Actions: `["read","write"]`, + Resources: `["user"]`, + Conditions: `{"resource.id":{"eq":"subject.id"}}`, + Priority: 50, + IsSystem: true, + Enabled: true, + }, + { + ID: "system:user-view-agents-list", + Name: "User View Agents List", + Effect: EffectAllow, + Subjects: `{"roles":["user"]}`, + Actions: `["read"]`, + Resources: `["agent_list"]`, + Conditions: `{}`, + Priority: 50, + IsSystem: true, + Enabled: true, + }, +} + +// SeedRolesAndPolicies ensures the built-in roles and policies exist in the +// store. It uses an idempotent pattern: existing entries are skipped. +func SeedRolesAndPolicies(ctx context.Context, store AuthStore) error { + for _, role := range builtinRoles { + if _, err := store.CreateRole(ctx, role); err != nil { + // If the role already exists (unique constraint), skip it. + if isAlreadyExists(err) { + slog.Debug("auth seed: role already exists", "role_id", role.ID) + continue + } + return fmt.Errorf("seed role %q: %w", role.ID, err) + } + slog.Info("auth seed: created role", "role_id", role.ID) + } + + for _, policy := range builtinPolicies { + if _, err := store.CreatePolicy(ctx, policy); err != nil { + if isAlreadyExists(err) { + slog.Debug("auth seed: policy already exists", "policy_id", policy.ID) + continue + } + return fmt.Errorf("seed policy %q: %w", policy.ID, err) + } + slog.Info("auth seed: created policy", "policy_id", policy.ID) + } + + return nil +} + +// isAlreadyExists checks whether the error indicates a unique constraint +// violation (SQLite UNIQUE constraint failed). +func isAlreadyExists(err error) bool { + if err == nil { + return false + } + // sql.ErrNoRows is not a constraint error, but check common patterns. + if errors.Is(err, sql.ErrNoRows) { + return false + } + // SQLite returns "UNIQUE constraint failed" for duplicate keys. + errStr := err.Error() + return contains(errStr, "UNIQUE constraint failed") || + contains(errStr, "constraint failed") || + contains(errStr, "duplicate key") +} + +// contains is a simple substring check (avoids importing strings just +// for this). +func contains(s, substr string) bool { + return len(s) >= len(substr) && searchSubstring(s, substr) +} + +func searchSubstring(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} From 1ac9ee1c71efb50350581bd5118107682099914f Mon Sep 17 00:00:00 2001 From: Vaayne Date: Fri, 20 Mar 2026 19:01:58 +0800 Subject: [PATCH 09/53] =?UTF-8?q?=E2=9C=A8=20feat:=20integrate=20auth=20se?= =?UTF-8?q?ed=20into=20bootstrap?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Calls auth.SeedRolesAndPolicies after DB migrations and config seed, ensuring built-in roles and policies exist on every startup. --- cmd/anna/commands.go | 8 ++++++++ go.mod | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/cmd/anna/commands.go b/cmd/anna/commands.go index 9b0d8501..c0460c99 100644 --- a/cmd/anna/commands.go +++ b/cmd/anna/commands.go @@ -13,6 +13,8 @@ import ( "github.com/vaayne/anna/internal/agent" "github.com/vaayne/anna/internal/agent/runner" agenttool "github.com/vaayne/anna/internal/agent/tool" + "github.com/vaayne/anna/internal/auth" + "github.com/vaayne/anna/internal/auth/authdb" "github.com/vaayne/anna/internal/channel" "github.com/vaayne/anna/internal/config" appdb "github.com/vaayne/anna/internal/db" @@ -70,6 +72,12 @@ func setup(parent context.Context, gateway bool) (*setupResult, error) { return nil, fmt.Errorf("seed defaults: %w", err) } + // Seed auth roles and policies. + authStore := authdb.New(db) + if err := auth.SeedRolesAndPolicies(parent, authStore); err != nil { + return nil, fmt.Errorf("seed auth: %w", err) + } + // Get snapshot for the default agent (used for global settings). agents, err := store.ListEnabledAgents(parent) if err != nil || len(agents) == 0 { diff --git a/go.mod b/go.mod index e056384b..2b074606 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( github.com/urfave/cli/v2 v2.27.7 github.com/vaayne/mcphub v0.2.4 github.com/yuin/goldmark v1.7.8 + golang.org/x/crypto v0.45.0 golang.org/x/oauth2 v0.30.0 golang.org/x/sync v0.18.0 gopkg.in/telebot.v4 v4.0.0-beta.7 @@ -93,7 +94,6 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect github.com/yuin/goldmark-emoji v1.0.5 // indirect - golang.org/x/crypto v0.45.0 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/net v0.47.0 // indirect golang.org/x/sys v0.38.0 // indirect From 615f4fac06a1ede335987f291f142545187a42ee Mon Sep 17 00:00:00 2001 From: Vaayne Date: Fri, 20 Mar 2026 19:02:10 +0800 Subject: [PATCH 10/53] =?UTF-8?q?=E2=9C=85=20test:=20add=20comprehensive?= =?UTF-8?q?=20tests=20for=20policy=20engine,=20conditions,=20and=20seed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests cover: - Condition evaluation (eq, neq, in, not_in, contains, attr refs, AND logic) - Policy matching (roles, actions, resources, wildcards, invalid JSON) - Deny-overrides (deny wins, default deny, allow when matched) - Built-in policy scenarios (admin full access, user own data, etc.) - Seed idempotency (run twice without error) - Engine creation from seeded DB --- internal/auth/condition_test.go | 225 ++++++++++++++ internal/auth/engine_test.go | 516 ++++++++++++++++++++++++++++++++ internal/auth/seed_test.go | 138 +++++++++ 3 files changed, 879 insertions(+) create mode 100644 internal/auth/condition_test.go create mode 100644 internal/auth/engine_test.go create mode 100644 internal/auth/seed_test.go diff --git a/internal/auth/condition_test.go b/internal/auth/condition_test.go new file mode 100644 index 00000000..a9363895 --- /dev/null +++ b/internal/auth/condition_test.go @@ -0,0 +1,225 @@ +package auth + +import "testing" + +func TestEvaluateConditions_Empty(t *testing.T) { + req := AccessRequest{} + for _, cond := range []string{"", "{}", `{}`} { + if !evaluateConditions(cond, req) { + t.Errorf("expected empty conditions %q to match", cond) + } + } +} + +func TestEvaluateConditions_InvalidJSON(t *testing.T) { + req := AccessRequest{} + if evaluateConditions("not-json", req) { + t.Error("expected invalid JSON conditions to fail") + } +} + +func TestEvaluateConditions_EqLiteral(t *testing.T) { + req := AccessRequest{ + Resource: Resource{ + Attrs: map[string]any{"scope": "system"}, + }, + } + cond := `{"resource.scope":{"eq":"system"}}` + if !evaluateConditions(cond, req) { + t.Error("expected eq literal to match") + } + + cond2 := `{"resource.scope":{"eq":"private"}}` + if evaluateConditions(cond2, req) { + t.Error("expected eq literal mismatch to fail") + } +} + +func TestEvaluateConditions_EqAttrRef(t *testing.T) { + req := AccessRequest{ + Subject: Subject{UserID: 42}, + Resource: Resource{OwnerID: 42}, + } + cond := `{"resource.owner_id":{"eq":"subject.id"}}` + if !evaluateConditions(cond, req) { + t.Error("expected owner_id == subject.id to match") + } + + req.Resource.OwnerID = 99 + if evaluateConditions(cond, req) { + t.Error("expected owner_id != subject.id to fail") + } +} + +func TestEvaluateConditions_Neq(t *testing.T) { + req := AccessRequest{ + Resource: Resource{ + Attrs: map[string]any{"status": "active"}, + }, + } + cond := `{"resource.status":{"neq":"disabled"}}` + if !evaluateConditions(cond, req) { + t.Error("expected neq to match") + } + + cond2 := `{"resource.status":{"neq":"active"}}` + if evaluateConditions(cond2, req) { + t.Error("expected neq same value to fail") + } +} + +func TestEvaluateConditions_In(t *testing.T) { + req := AccessRequest{ + Subject: Subject{ + UserID: 1, + AgentIDs: []string{"agent-a", "agent-b", "agent-c"}, + }, + Resource: Resource{ID: "agent-b"}, + } + cond := `{"resource.id":{"in":"subject.agent_ids"}}` + if !evaluateConditions(cond, req) { + t.Error("expected 'in' to match") + } + + req.Resource.ID = "agent-z" + if evaluateConditions(cond, req) { + t.Error("expected 'in' to fail for non-member") + } +} + +func TestEvaluateConditions_InLiteralArray(t *testing.T) { + req := AccessRequest{ + Resource: Resource{ + Attrs: map[string]any{"scope": "system"}, + }, + } + // Right operand is a literal JSON array embedded as the value. + cond := `{"resource.scope":{"in":["system","public"]}}` + if !evaluateConditions(cond, req) { + t.Error("expected 'in' literal array to match") + } + + req.Resource.Attrs["scope"] = "private" + if evaluateConditions(cond, req) { + t.Error("expected 'in' literal array to fail for non-member") + } +} + +func TestEvaluateConditions_NotIn(t *testing.T) { + req := AccessRequest{ + Subject: Subject{ + AgentIDs: []string{"a", "b"}, + }, + Resource: Resource{ID: "c"}, + } + cond := `{"resource.id":{"not_in":"subject.agent_ids"}}` + if !evaluateConditions(cond, req) { + t.Error("expected not_in to match when not a member") + } + + req.Resource.ID = "a" + if evaluateConditions(cond, req) { + t.Error("expected not_in to fail when is a member") + } +} + +func TestEvaluateConditions_Contains(t *testing.T) { + req := AccessRequest{ + Subject: Subject{ + Roles: []string{"admin", "user"}, + }, + } + // "subject.roles" contains "admin" + cond := `{"subject.roles":{"contains":"admin"}}` + if !evaluateConditions(cond, req) { + t.Error("expected contains to match") + } + + cond2 := `{"subject.roles":{"contains":"superadmin"}}` + if evaluateConditions(cond2, req) { + t.Error("expected contains to fail for non-member") + } +} + +func TestEvaluateConditions_MultipleConditionsAND(t *testing.T) { + req := AccessRequest{ + Subject: Subject{UserID: 5}, + Resource: Resource{ + OwnerID: 5, + Attrs: map[string]any{"scope": "system"}, + }, + } + cond := `{"resource.owner_id":{"eq":"subject.id"},"resource.scope":{"eq":"system"}}` + if !evaluateConditions(cond, req) { + t.Error("expected both conditions to match") + } + + // Change scope so second condition fails. + req.Resource.Attrs["scope"] = "private" + if evaluateConditions(cond, req) { + t.Error("expected AND to fail when one condition fails") + } +} + +func TestEvaluateConditions_UnknownOperator(t *testing.T) { + req := AccessRequest{ + Resource: Resource{Attrs: map[string]any{"x": "1"}}, + } + cond := `{"resource.x":{"gt":"0"}}` + if evaluateConditions(cond, req) { + t.Error("expected unknown operator to fail") + } +} + +func TestResolveAttribute_InvalidPath(t *testing.T) { + req := AccessRequest{} + if v := resolveAttribute("noprefix", req); v != "" { + t.Errorf("expected empty, got %q", v) + } + if v := resolveAttribute("unknown.field", req); v != "" { + t.Errorf("expected empty for unknown prefix, got %q", v) + } +} + +func TestResolveSubjectAttr_CustomAttrs(t *testing.T) { + s := Subject{Attrs: map[string]any{"department": "engineering"}} + if v := resolveSubjectAttr("department", s); v != "engineering" { + t.Errorf("expected 'engineering', got %q", v) + } +} + +func TestResolveResourceAttr_CustomAttrs(t *testing.T) { + r := Resource{Attrs: map[string]any{"scope": "system"}} + if v := resolveResourceAttr("scope", r); v != "system" { + t.Errorf("expected 'system', got %q", v) + } +} + +func TestResolveResourceAttr_NilAttrs(t *testing.T) { + r := Resource{} + if v := resolveResourceAttr("unknown", r); v != "" { + t.Errorf("expected empty, got %q", v) + } +} + +func TestAnyToString(t *testing.T) { + tests := []struct { + input any + want string + }{ + {nil, ""}, + {"hello", "hello"}, + {float64(42), "42"}, + {float64(3.14), "3.14"}, + {int64(10), "10"}, + {int(7), "7"}, + {true, "true"}, + {false, "false"}, + } + for _, tt := range tests { + got := anyToString(tt.input) + if got != tt.want { + t.Errorf("anyToString(%v) = %q, want %q", tt.input, got, tt.want) + } + } +} diff --git a/internal/auth/engine_test.go b/internal/auth/engine_test.go new file mode 100644 index 00000000..cc59a84e --- /dev/null +++ b/internal/auth/engine_test.go @@ -0,0 +1,516 @@ +package auth + +import ( + "context" + "testing" +) + +// --- Policy matching tests --- + +func TestMatchSubjects_Wildcard(t *testing.T) { + if !matchSubjects(`{"roles":["*"]}`, Subject{Roles: []string{"user"}}) { + t.Error("wildcard should match any role") + } +} + +func TestMatchSubjects_SpecificRole(t *testing.T) { + if !matchSubjects(`{"roles":["admin"]}`, Subject{Roles: []string{"admin", "user"}}) { + t.Error("should match when subject has the role") + } + if matchSubjects(`{"roles":["admin"]}`, Subject{Roles: []string{"user"}}) { + t.Error("should not match when subject lacks the role") + } +} + +func TestMatchSubjects_EmptyJSON(t *testing.T) { + if !matchSubjects("", Subject{}) { + t.Error("empty subjects should match") + } + if !matchSubjects("{}", Subject{}) { + t.Error("empty object subjects should match") + } +} + +func TestMatchSubjects_EmptyRoles(t *testing.T) { + if !matchSubjects(`{"roles":[]}`, Subject{Roles: []string{"user"}}) { + t.Error("empty roles array should match any subject") + } +} + +func TestMatchSubjects_InvalidJSON(t *testing.T) { + if matchSubjects("invalid", Subject{Roles: []string{"admin"}}) { + t.Error("invalid JSON should not match") + } +} + +func TestMatchActions_Wildcard(t *testing.T) { + if !matchActions(`["*"]`, ActionRead) { + t.Error("wildcard should match any action") + } +} + +func TestMatchActions_Specific(t *testing.T) { + if !matchActions(`["read","write"]`, ActionRead) { + t.Error("should match listed action") + } + if matchActions(`["read","write"]`, ActionDelete) { + t.Error("should not match unlisted action") + } +} + +func TestMatchActions_Empty(t *testing.T) { + if !matchActions("", ActionRead) { + t.Error("empty actions should match") + } + if !matchActions("[]", ActionRead) { + t.Error("empty array actions should match") + } +} + +func TestMatchActions_InvalidJSON(t *testing.T) { + if matchActions("invalid", ActionRead) { + t.Error("invalid JSON should not match") + } +} + +func TestMatchResources_Wildcard(t *testing.T) { + if !matchResources(`["*"]`, Resource{Type: ResourceAgent}) { + t.Error("wildcard should match any resource") + } +} + +func TestMatchResources_Specific(t *testing.T) { + if !matchResources(`["agent","provider"]`, Resource{Type: ResourceAgent}) { + t.Error("should match listed resource") + } + if matchResources(`["agent","provider"]`, Resource{Type: ResourceSession}) { + t.Error("should not match unlisted resource") + } +} + +func TestMatchResources_Empty(t *testing.T) { + if !matchResources("", Resource{Type: ResourceAgent}) { + t.Error("empty resources should match") + } +} + +func TestMatchResources_InvalidJSON(t *testing.T) { + if matchResources("invalid", Resource{Type: ResourceAgent}) { + t.Error("invalid JSON should not match") + } +} + +// --- Deny-overrides tests --- + +func TestEngine_DenyOverrides(t *testing.T) { + policies := []Policy{ + { + ID: "allow-all", + Effect: EffectAllow, + Subjects: `{"roles":["user"]}`, + Actions: `["*"]`, + Resources: `["*"]`, + Conditions: `{}`, + Priority: 10, + Enabled: true, + }, + { + ID: "deny-delete", + Effect: EffectDeny, + Subjects: `{"roles":["user"]}`, + Actions: `["delete"]`, + Resources: `["agent"]`, + Conditions: `{}`, + Priority: 20, + Enabled: true, + }, + } + + engine := NewEngineFromPolicies(policies) + ctx := context.Background() + + // Read should be allowed. + if !engine.Can(ctx, AccessRequest{ + Subject: Subject{Roles: []string{"user"}}, + Action: ActionRead, + Resource: Resource{Type: ResourceAgent}, + }) { + t.Error("read should be allowed") + } + + // Delete agent should be denied (deny overrides allow). + if engine.Can(ctx, AccessRequest{ + Subject: Subject{Roles: []string{"user"}}, + Action: ActionDelete, + Resource: Resource{Type: ResourceAgent}, + }) { + t.Error("delete agent should be denied") + } +} + +func TestEngine_DefaultDeny(t *testing.T) { + // No policies at all -> default deny. + engine := NewEngineFromPolicies(nil) + ctx := context.Background() + + if engine.Can(ctx, AccessRequest{ + Subject: Subject{Roles: []string{"user"}}, + Action: ActionRead, + Resource: Resource{Type: ResourceAgent}, + }) { + t.Error("should default deny with no policies") + } +} + +func TestEngine_NoMatchingPolicies(t *testing.T) { + policies := []Policy{ + { + ID: "admin-only", + Effect: EffectAllow, + Subjects: `{"roles":["admin"]}`, + Actions: `["*"]`, + Resources: `["*"]`, + Conditions: `{}`, + Priority: 100, + Enabled: true, + }, + } + + engine := NewEngineFromPolicies(policies) + ctx := context.Background() + + // User (not admin) should be denied. + if engine.Can(ctx, AccessRequest{ + Subject: Subject{Roles: []string{"user"}}, + Action: ActionRead, + Resource: Resource{Type: ResourceAgent}, + }) { + t.Error("non-admin should be denied when only admin policy exists") + } +} + +func TestEngine_AllowWhenMatched(t *testing.T) { + policies := []Policy{ + { + ID: "allow-user-read", + Effect: EffectAllow, + Subjects: `{"roles":["user"]}`, + Actions: `["read"]`, + Resources: `["agent"]`, + Conditions: `{}`, + Priority: 50, + Enabled: true, + }, + } + + engine := NewEngineFromPolicies(policies) + ctx := context.Background() + + if !engine.Can(ctx, AccessRequest{ + Subject: Subject{Roles: []string{"user"}}, + Action: ActionRead, + Resource: Resource{Type: ResourceAgent}, + }) { + t.Error("should allow when policy matches") + } +} + +func TestEngine_ConditionBasedDenial(t *testing.T) { + policies := []Policy{ + { + ID: "allow-own-data", + Effect: EffectAllow, + Subjects: `{"roles":["user"]}`, + Actions: `["read","write"]`, + Resources: `["user_data"]`, + Conditions: `{"resource.owner_id":{"eq":"subject.id"}}`, + Priority: 50, + Enabled: true, + }, + } + + engine := NewEngineFromPolicies(policies) + ctx := context.Background() + + // Own data: allowed. + if !engine.Can(ctx, AccessRequest{ + Subject: Subject{UserID: 1, Roles: []string{"user"}}, + Action: ActionRead, + Resource: Resource{Type: ResourceUserData, OwnerID: 1}, + }) { + t.Error("should allow access to own data") + } + + // Other's data: denied (condition doesn't match -> no allow policy matches). + if engine.Can(ctx, AccessRequest{ + Subject: Subject{UserID: 1, Roles: []string{"user"}}, + Action: ActionRead, + Resource: Resource{Type: ResourceUserData, OwnerID: 99}, + }) { + t.Error("should deny access to other user's data") + } +} + +func TestEngine_PriorityOrdering(t *testing.T) { + // Lower priority allow, higher priority deny -> deny wins. + policies := []Policy{ + { + ID: "low-allow", + Effect: EffectAllow, + Subjects: `{"roles":["user"]}`, + Actions: `["read"]`, + Resources: `["agent"]`, + Conditions: `{}`, + Priority: 10, + Enabled: true, + }, + { + ID: "high-deny", + Effect: EffectDeny, + Subjects: `{"roles":["user"]}`, + Actions: `["read"]`, + Resources: `["agent"]`, + Conditions: `{}`, + Priority: 100, + Enabled: true, + }, + } + + engine := NewEngineFromPolicies(policies) + ctx := context.Background() + + if engine.Can(ctx, AccessRequest{ + Subject: Subject{Roles: []string{"user"}}, + Action: ActionRead, + Resource: Resource{Type: ResourceAgent}, + }) { + t.Error("deny should override allow regardless of priority") + } +} + +func TestEngine_Must(t *testing.T) { + engine := NewEngineFromPolicies(nil) + ctx := context.Background() + + err := engine.Must(ctx, AccessRequest{ + Subject: Subject{Roles: []string{"user"}}, + Action: ActionRead, + Resource: Resource{Type: ResourceAgent}, + }) + if err == nil { + t.Error("Must should return error when denied") + } + if err != ErrAccessDenied { + t.Errorf("expected ErrAccessDenied, got %v", err) + } +} + +func TestEngine_MustAllowed(t *testing.T) { + policies := []Policy{ + { + ID: "allow", + Effect: EffectAllow, + Subjects: `{"roles":["*"]}`, + Actions: `["*"]`, + Resources: `["*"]`, + Conditions: `{}`, + Priority: 1, + Enabled: true, + }, + } + + engine := NewEngineFromPolicies(policies) + ctx := context.Background() + + if err := engine.Must(ctx, AccessRequest{ + Subject: Subject{Roles: []string{"user"}}, + Action: ActionRead, + Resource: Resource{Type: ResourceAgent}, + }); err != nil { + t.Errorf("Must should return nil when allowed, got %v", err) + } +} + +func TestEngine_MultipleRolesOnSubject(t *testing.T) { + policies := []Policy{ + { + ID: "admin-full", + Effect: EffectAllow, + Subjects: `{"roles":["admin"]}`, + Actions: `["*"]`, + Resources: `["*"]`, + Conditions: `{}`, + Priority: 100, + Enabled: true, + }, + } + + engine := NewEngineFromPolicies(policies) + ctx := context.Background() + + // Subject with both admin and user roles should match admin policy. + if !engine.Can(ctx, AccessRequest{ + Subject: Subject{Roles: []string{"user", "admin"}}, + Action: ActionManage, + Resource: Resource{Type: ResourceSetting}, + }) { + t.Error("should allow when subject has matching role among multiple") + } +} + +func TestEngine_ConflictingPolicies_DenyWins(t *testing.T) { + policies := []Policy{ + { + ID: "allow-read", + Effect: EffectAllow, + Subjects: `{"roles":["user"]}`, + Actions: `["read"]`, + Resources: `["agent"]`, + Conditions: `{}`, + Priority: 50, + Enabled: true, + }, + { + ID: "deny-read", + Effect: EffectDeny, + Subjects: `{"roles":["user"]}`, + Actions: `["read"]`, + Resources: `["agent"]`, + Conditions: `{}`, + Priority: 50, + Enabled: true, + }, + } + + engine := NewEngineFromPolicies(policies) + ctx := context.Background() + + if engine.Can(ctx, AccessRequest{ + Subject: Subject{Roles: []string{"user"}}, + Action: ActionRead, + Resource: Resource{Type: ResourceAgent}, + }) { + t.Error("deny should win over allow at same priority") + } +} + +// --- Built-in policy scenario tests --- + +func TestEngine_BuiltinPolicies_AdminFullAccess(t *testing.T) { + engine := NewEngineFromPolicies(builtinPolicies) + ctx := context.Background() + + // Admin can do anything. + for _, action := range []Action{ActionRead, ActionWrite, ActionCreate, ActionDelete, ActionManage} { + for _, res := range []ResourceType{ResourceAgent, ResourceProvider, ResourceSession, ResourceUser} { + if !engine.Can(ctx, AccessRequest{ + Subject: Subject{UserID: 1, Roles: []string{"admin"}}, + Action: action, + Resource: Resource{Type: res}, + }) { + t.Errorf("admin should have full access: action=%s resource=%s", action, res) + } + } + } +} + +func TestEngine_BuiltinPolicies_UserOwnData(t *testing.T) { + engine := NewEngineFromPolicies(builtinPolicies) + ctx := context.Background() + + // User can read own data. + if !engine.Can(ctx, AccessRequest{ + Subject: Subject{UserID: 5, Roles: []string{"user"}}, + Action: ActionRead, + Resource: Resource{Type: ResourceUserData, OwnerID: 5}, + }) { + t.Error("user should be able to read own data") + } + + // User cannot read other's data. + if engine.Can(ctx, AccessRequest{ + Subject: Subject{UserID: 5, Roles: []string{"user"}}, + Action: ActionRead, + Resource: Resource{Type: ResourceUserData, OwnerID: 99}, + }) { + t.Error("user should not be able to read other's data") + } +} + +func TestEngine_BuiltinPolicies_UserSystemAgents(t *testing.T) { + engine := NewEngineFromPolicies(builtinPolicies) + ctx := context.Background() + + // User can read system-scoped agents. + if !engine.Can(ctx, AccessRequest{ + Subject: Subject{UserID: 2, Roles: []string{"user"}}, + Action: ActionRead, + Resource: Resource{ + Type: ResourceAgent, + Attrs: map[string]any{"scope": "system"}, + }, + }) { + t.Error("user should be able to read system agents") + } +} + +func TestEngine_BuiltinPolicies_UserAssignedAgents(t *testing.T) { + engine := NewEngineFromPolicies(builtinPolicies) + ctx := context.Background() + + // User can execute assigned agent. + if !engine.Can(ctx, AccessRequest{ + Subject: Subject{ + UserID: 2, + Roles: []string{"user"}, + AgentIDs: []string{"agent-x", "agent-y"}, + }, + Action: ActionExecute, + Resource: Resource{Type: ResourceAgent, ID: "agent-x"}, + }) { + t.Error("user should be able to execute assigned agent") + } + + // User cannot execute unassigned agent (that is not system scope). + if engine.Can(ctx, AccessRequest{ + Subject: Subject{ + UserID: 2, + Roles: []string{"user"}, + AgentIDs: []string{"agent-x"}, + }, + Action: ActionExecute, + Resource: Resource{ + Type: ResourceAgent, + ID: "agent-z", + Attrs: map[string]any{"scope": "private"}, + }, + }) { + t.Error("user should not be able to execute unassigned non-system agent") + } +} + +func TestEngine_BuiltinPolicies_UserViewAgentsList(t *testing.T) { + engine := NewEngineFromPolicies(builtinPolicies) + ctx := context.Background() + + if !engine.Can(ctx, AccessRequest{ + Subject: Subject{UserID: 2, Roles: []string{"user"}}, + Action: ActionRead, + Resource: Resource{Type: ResourceAgentList}, + }) { + t.Error("user should be able to view agents list") + } +} + +func TestEngine_BuiltinPolicies_UserCannotManageProviders(t *testing.T) { + engine := NewEngineFromPolicies(builtinPolicies) + ctx := context.Background() + + if engine.Can(ctx, AccessRequest{ + Subject: Subject{UserID: 2, Roles: []string{"user"}}, + Action: ActionManage, + Resource: Resource{Type: ResourceProvider}, + }) { + t.Error("user should not be able to manage providers") + } +} diff --git a/internal/auth/seed_test.go b/internal/auth/seed_test.go new file mode 100644 index 00000000..82822d1e --- /dev/null +++ b/internal/auth/seed_test.go @@ -0,0 +1,138 @@ +package auth_test + +import ( + "context" + "path/filepath" + "testing" + + "github.com/vaayne/anna/internal/auth" + "github.com/vaayne/anna/internal/auth/authdb" + appdb "github.com/vaayne/anna/internal/db" +) + +func setupSeedStore(t *testing.T) auth.AuthStore { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "test.db") + db, err := appdb.OpenDB(dbPath) + if err != nil { + t.Fatalf("OpenDB: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + return authdb.New(db) +} + +func TestSeedRolesAndPolicies(t *testing.T) { + store := setupSeedStore(t) + ctx := context.Background() + + if err := auth.SeedRolesAndPolicies(ctx, store); err != nil { + t.Fatalf("SeedRolesAndPolicies: %v", err) + } + + // Check roles. + roles, err := store.ListRoles(ctx) + if err != nil { + t.Fatalf("ListRoles: %v", err) + } + if len(roles) != 2 { + t.Errorf("expected 2 roles, got %d", len(roles)) + } + + roleIDs := make(map[string]bool) + for _, r := range roles { + roleIDs[r.ID] = true + if !r.IsSystem { + t.Errorf("role %q should be system", r.ID) + } + } + if !roleIDs["admin"] { + t.Error("missing admin role") + } + if !roleIDs["user"] { + t.Error("missing user role") + } + + // Check policies. + policies, err := store.ListEnabledPolicies(ctx) + if err != nil { + t.Fatalf("ListEnabledPolicies: %v", err) + } + if len(policies) != 8 { + t.Errorf("expected 8 policies, got %d", len(policies)) + } + + policyIDs := make(map[string]bool) + for _, p := range policies { + policyIDs[p.ID] = true + } + expectedPolicies := []string{ + "system:admin-full-access", + "system:user-system-agents", + "system:user-assigned-agents", + "system:user-own-sessions", + "system:user-own-data", + "system:user-own-skills", + "system:user-own-profile", + "system:user-view-agents-list", + } + for _, id := range expectedPolicies { + if !policyIDs[id] { + t.Errorf("missing policy %q", id) + } + } +} + +func TestSeedRolesAndPolicies_Idempotent(t *testing.T) { + store := setupSeedStore(t) + ctx := context.Background() + + // Seed twice — should not error. + if err := auth.SeedRolesAndPolicies(ctx, store); err != nil { + t.Fatalf("first seed: %v", err) + } + if err := auth.SeedRolesAndPolicies(ctx, store); err != nil { + t.Fatalf("second seed should be idempotent: %v", err) + } + + // Still only 2 roles and 8 policies. + roles, _ := store.ListRoles(ctx) + if len(roles) != 2 { + t.Errorf("expected 2 roles after double seed, got %d", len(roles)) + } + policies, _ := store.ListEnabledPolicies(ctx) + if len(policies) != 8 { + t.Errorf("expected 8 policies after double seed, got %d", len(policies)) + } +} + +func TestNewEngine_WithSeededPolicies(t *testing.T) { + store := setupSeedStore(t) + ctx := context.Background() + + if err := auth.SeedRolesAndPolicies(ctx, store); err != nil { + t.Fatalf("SeedRolesAndPolicies: %v", err) + } + + engine, err := auth.NewEngine(ctx, store) + if err != nil { + t.Fatalf("NewEngine: %v", err) + } + + // Admin can manage anything. + if !engine.Can(ctx, auth.AccessRequest{ + Subject: auth.Subject{UserID: 1, Roles: []string{"admin"}}, + Action: auth.ActionManage, + Resource: auth.Resource{Type: auth.ResourceSetting}, + }) { + t.Error("admin should have full access from seeded policies") + } + + // Regular user cannot manage settings. + if engine.Can(ctx, auth.AccessRequest{ + Subject: auth.Subject{UserID: 2, Roles: []string{"user"}}, + Action: auth.ActionManage, + Resource: auth.Resource{Type: auth.ResourceSetting}, + }) { + t.Error("user should not be able to manage settings") + } +} From ca2802646dcc4b8f312991e9181de70ac7f454fc Mon Sep 17 00:00:00 2001 From: Vaayne Date: Fri, 20 Mar 2026 19:05:26 +0800 Subject: [PATCH 11/53] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20use=20st?= =?UTF-8?q?rings.Contains=20in=20auth=20seed=20instead=20of=20custom=20imp?= =?UTF-8?q?l?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/auth/seed.go | 22 ++++------------------ 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/internal/auth/seed.go b/internal/auth/seed.go index 2a0af059..027e4bba 100644 --- a/internal/auth/seed.go +++ b/internal/auth/seed.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "log/slog" + "strings" ) // builtinRoles defines the system roles seeded on bootstrap. @@ -165,22 +166,7 @@ func isAlreadyExists(err error) bool { } // SQLite returns "UNIQUE constraint failed" for duplicate keys. errStr := err.Error() - return contains(errStr, "UNIQUE constraint failed") || - contains(errStr, "constraint failed") || - contains(errStr, "duplicate key") -} - -// contains is a simple substring check (avoids importing strings just -// for this). -func contains(s, substr string) bool { - return len(s) >= len(substr) && searchSubstring(s, substr) -} - -func searchSubstring(s, substr string) bool { - for i := 0; i <= len(s)-len(substr); i++ { - if s[i:i+len(substr)] == substr { - return true - } - } - return false + return strings.Contains(errStr, "UNIQUE constraint failed") || + strings.Contains(errStr, "constraint failed") || + strings.Contains(errStr, "duplicate key") } From abea062e9cb1048553fb892aacd2e482a793433c Mon Sep 17 00:00:00 2001 From: Vaayne Date: Fri, 20 Mar 2026 19:21:46 +0800 Subject: [PATCH 12/53] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20session=20manag?= =?UTF-8?q?ement=20and=20rate=20limiting=20for=20auth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - session.go: crypto/rand session IDs (32 bytes hex), cookie helpers (HttpOnly, SameSite=Lax, Secure flag, 7-day MaxAge) - ratelimit.go: in-memory rate limiter with per-IP (10 req/min) and per-username (5 failures -> 30s cooldown) throttling via sync.Map - Tests for both packages with -race --- internal/auth/ratelimit.go | 116 ++++++++++++++++++++++++++++++++ internal/auth/ratelimit_test.go | 86 +++++++++++++++++++++++ internal/auth/session.go | 72 ++++++++++++++++++++ internal/auth/session_test.go | 115 +++++++++++++++++++++++++++++++ 4 files changed, 389 insertions(+) create mode 100644 internal/auth/ratelimit.go create mode 100644 internal/auth/ratelimit_test.go create mode 100644 internal/auth/session.go create mode 100644 internal/auth/session_test.go diff --git a/internal/auth/ratelimit.go b/internal/auth/ratelimit.go new file mode 100644 index 00000000..3e5551e6 --- /dev/null +++ b/internal/auth/ratelimit.go @@ -0,0 +1,116 @@ +package auth + +import ( + "errors" + "sync" + "time" +) + +// Rate limiting constants. +const ( + ipMaxAttempts = 10 + ipWindowDuration = time.Minute + usernameMaxFailures = 5 + usernameCooldown = 30 * time.Second +) + +// Rate limiting errors. +var ( + ErrRateLimitIP = errors.New("too many requests from this IP, try again later") + ErrRateLimitUsername = errors.New("too many failed attempts for this account, try again in 30 seconds") +) + +// ipRecord tracks login attempts per IP address. +type ipRecord struct { + mu sync.Mutex + attempts int + windowAt time.Time +} + +// usernameRecord tracks consecutive login failures per username. +type usernameRecord struct { + mu sync.Mutex + failures int + lastFailure time.Time +} + +// RateLimiter provides per-IP and per-username rate limiting for login attempts. +type RateLimiter struct { + ips sync.Map // string -> *ipRecord + usernames sync.Map // string -> *usernameRecord +} + +// NewRateLimiter creates a new RateLimiter. +func NewRateLimiter() *RateLimiter { + return &RateLimiter{} +} + +// CheckIP verifies the IP has not exceeded the request limit. +func (rl *RateLimiter) CheckIP(ip string) error { + now := time.Now() + + val, _ := rl.ips.LoadOrStore(ip, &ipRecord{windowAt: now}) + rec := val.(*ipRecord) + + rec.mu.Lock() + defer rec.mu.Unlock() + + // Reset window if expired. + if now.Sub(rec.windowAt) > ipWindowDuration { + rec.attempts = 0 + rec.windowAt = now + } + + if rec.attempts >= ipMaxAttempts { + return ErrRateLimitIP + } + + rec.attempts++ + return nil +} + +// CheckUsername verifies the username is not in a cooldown period. +func (rl *RateLimiter) CheckUsername(username string) error { + val, ok := rl.usernames.Load(username) + if !ok { + return nil + } + + rec := val.(*usernameRecord) + rec.mu.Lock() + defer rec.mu.Unlock() + + if rec.failures >= usernameMaxFailures { + if time.Since(rec.lastFailure) < usernameCooldown { + return ErrRateLimitUsername + } + // Cooldown expired, reset. + rec.failures = 0 + } + + return nil +} + +// RecordLoginFailure records a failed login attempt for a username. +func (rl *RateLimiter) RecordLoginFailure(username string) { + now := time.Now() + + val, _ := rl.usernames.LoadOrStore(username, &usernameRecord{}) + rec := val.(*usernameRecord) + + rec.mu.Lock() + defer rec.mu.Unlock() + + // Reset if cooldown has passed. + if rec.failures >= usernameMaxFailures && time.Since(rec.lastFailure) >= usernameCooldown { + rec.failures = 0 + } + + rec.failures++ + rec.lastFailure = now +} + +// RecordLoginSuccess resets the failure counter for a username. +func (rl *RateLimiter) RecordLoginSuccess(username string) { + rl.usernames.Delete(username) +} diff --git a/internal/auth/ratelimit_test.go b/internal/auth/ratelimit_test.go new file mode 100644 index 00000000..2d70f07e --- /dev/null +++ b/internal/auth/ratelimit_test.go @@ -0,0 +1,86 @@ +package auth_test + +import ( + "testing" + + "github.com/vaayne/anna/internal/auth" +) + +func TestRateLimiterIPBasic(t *testing.T) { + rl := auth.NewRateLimiter() + + // Should allow 10 attempts. + for i := 0; i < 10; i++ { + if err := rl.CheckIP("1.2.3.4"); err != nil { + t.Fatalf("attempt %d: unexpected error: %v", i+1, err) + } + } + + // 11th should fail. + if err := rl.CheckIP("1.2.3.4"); err != auth.ErrRateLimitIP { + t.Errorf("expected ErrRateLimitIP, got %v", err) + } + + // Different IP should still work. + if err := rl.CheckIP("5.6.7.8"); err != nil { + t.Fatalf("different IP should not be rate limited: %v", err) + } +} + +func TestRateLimiterUsernameFailures(t *testing.T) { + rl := auth.NewRateLimiter() + + // No failures yet, should pass. + if err := rl.CheckUsername("testuser"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Record 5 failures. + for i := 0; i < 5; i++ { + rl.RecordLoginFailure("testuser") + } + + // Should be rate limited. + if err := rl.CheckUsername("testuser"); err != auth.ErrRateLimitUsername { + t.Errorf("expected ErrRateLimitUsername, got %v", err) + } + + // Different username should work. + if err := rl.CheckUsername("otheruser"); err != nil { + t.Fatalf("different user should not be rate limited: %v", err) + } +} + +func TestRateLimiterLoginSuccess(t *testing.T) { + rl := auth.NewRateLimiter() + + // Record 4 failures. + for i := 0; i < 4; i++ { + rl.RecordLoginFailure("testuser") + } + + // Success should reset. + rl.RecordLoginSuccess("testuser") + + // Should be able to fail again. + for i := 0; i < 5; i++ { + rl.RecordLoginFailure("testuser") + } + + if err := rl.CheckUsername("testuser"); err != auth.ErrRateLimitUsername { + t.Errorf("expected ErrRateLimitUsername after new failures, got %v", err) + } +} + +func TestRateLimiterBelowThreshold(t *testing.T) { + rl := auth.NewRateLimiter() + + // 4 failures is below threshold. + for i := 0; i < 4; i++ { + rl.RecordLoginFailure("testuser") + } + + if err := rl.CheckUsername("testuser"); err != nil { + t.Errorf("4 failures should not trigger rate limit, got %v", err) + } +} diff --git a/internal/auth/session.go b/internal/auth/session.go new file mode 100644 index 00000000..8964aede --- /dev/null +++ b/internal/auth/session.go @@ -0,0 +1,72 @@ +package auth + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "net/http" + "time" +) + +const ( + // SessionCookieName is the HTTP cookie name for session tokens. + SessionCookieName = "anna_session" + + // SessionDuration is the default session lifetime. + SessionDuration = 7 * 24 * time.Hour + + // sessionIDBytes is the number of random bytes for a session ID. + sessionIDBytes = 32 +) + +// ErrNoSession is returned when no session cookie is present. +var ErrNoSession = errors.New("no session cookie") + +// NewSessionID generates a cryptographically random hex-encoded session ID +// (32 bytes = 64 hex characters). +func NewSessionID() string { + b := make([]byte, sessionIDBytes) + if _, err := rand.Read(b); err != nil { + panic("auth: crypto/rand failed: " + err.Error()) + } + return hex.EncodeToString(b) +} + +// SetSessionCookie writes the session cookie to the response. The Secure flag +// is set when secure is true (i.e., not localhost/dev). +func SetSessionCookie(w http.ResponseWriter, sessionID string, secure bool) { + http.SetCookie(w, &http.Cookie{ + Name: SessionCookieName, + Value: sessionID, + Path: "/", + MaxAge: int(SessionDuration.Seconds()), + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteLaxMode, + }) +} + +// ClearSessionCookie removes the session cookie from the response. +func ClearSessionCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: SessionCookieName, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + }) +} + +// GetSessionCookie extracts the session ID from the request cookie. +// Returns ErrNoSession if the cookie is missing or empty. +func GetSessionCookie(r *http.Request) (string, error) { + c, err := r.Cookie(SessionCookieName) + if err != nil { + return "", ErrNoSession + } + if c.Value == "" { + return "", ErrNoSession + } + return c.Value, nil +} diff --git a/internal/auth/session_test.go b/internal/auth/session_test.go new file mode 100644 index 00000000..8e687ed7 --- /dev/null +++ b/internal/auth/session_test.go @@ -0,0 +1,115 @@ +package auth_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/vaayne/anna/internal/auth" +) + +func TestNewSessionID(t *testing.T) { + id1 := auth.NewSessionID() + id2 := auth.NewSessionID() + + if len(id1) != 64 { + t.Errorf("session ID length = %d, want 64", len(id1)) + } + if id1 == id2 { + t.Error("two session IDs should not be equal") + } +} + +func TestSetAndGetSessionCookie(t *testing.T) { + rr := httptest.NewRecorder() + auth.SetSessionCookie(rr, "test-session-id", false) + + cookies := rr.Result().Cookies() + if len(cookies) == 0 { + t.Fatal("no cookies set") + } + + var found *http.Cookie + for _, c := range cookies { + if c.Name == auth.SessionCookieName { + found = c + break + } + } + if found == nil { + t.Fatal("session cookie not found") + } + if found.Value != "test-session-id" { + t.Errorf("cookie value = %q, want %q", found.Value, "test-session-id") + } + if !found.HttpOnly { + t.Error("cookie should be HttpOnly") + } + if found.SameSite != http.SameSiteLaxMode { + t.Error("cookie should be SameSite=Lax") + } + if found.Secure { + t.Error("cookie should not be Secure for non-secure") + } + if found.Path != "/" { + t.Errorf("cookie path = %q, want %q", found.Path, "/") + } + + // Test Get. + req := httptest.NewRequest("GET", "/", nil) + req.AddCookie(found) + id, err := auth.GetSessionCookie(req) + if err != nil { + t.Fatalf("GetSessionCookie: %v", err) + } + if id != "test-session-id" { + t.Errorf("got %q, want %q", id, "test-session-id") + } +} + +func TestSetSessionCookieSecure(t *testing.T) { + rr := httptest.NewRecorder() + auth.SetSessionCookie(rr, "secure-id", true) + + for _, c := range rr.Result().Cookies() { + if c.Name == auth.SessionCookieName { + if !c.Secure { + t.Error("cookie should be Secure") + } + return + } + } + t.Fatal("session cookie not found") +} + +func TestClearSessionCookie(t *testing.T) { + rr := httptest.NewRecorder() + auth.ClearSessionCookie(rr) + + for _, c := range rr.Result().Cookies() { + if c.Name == auth.SessionCookieName { + if c.MaxAge != -1 { + t.Errorf("MaxAge = %d, want -1", c.MaxAge) + } + return + } + } + t.Fatal("session cookie not found") +} + +func TestGetSessionCookieMissing(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + _, err := auth.GetSessionCookie(req) + if err != auth.ErrNoSession { + t.Errorf("err = %v, want ErrNoSession", err) + } +} + +func TestGetSessionCookieEmpty(t *testing.T) { + req := httptest.NewRequest("GET", "/", nil) + req.AddCookie(&http.Cookie{Name: auth.SessionCookieName, Value: ""}) + _, err := auth.GetSessionCookie(req) + if err != auth.ErrNoSession { + t.Errorf("err = %v, want ErrNoSession", err) + } +} From 610ec3ccd8272e9a79283f46f565df92334f3b95 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Fri, 20 Mar 2026 19:21:57 +0800 Subject: [PATCH 13/53] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20login/register?= =?UTF-8?q?=20page=20with=20standalone=20layout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - login.templ: login form with register toggle, daisyUI styled - login.js: Alpine.js component for login/register API calls - loginlayout.templ: standalone layout without navbar for login page --- internal/admin/ui/loginlayout.templ | 63 ++++++++++++ internal/admin/ui/loginlayout_templ.go | 79 +++++++++++++++ internal/admin/ui/pages/login.templ | 108 +++++++++++++++++++++ internal/admin/ui/pages/login_templ.go | 40 ++++++++ internal/admin/ui/static/js/pages/login.js | 80 +++++++++++++++ 5 files changed, 370 insertions(+) create mode 100644 internal/admin/ui/loginlayout.templ create mode 100644 internal/admin/ui/loginlayout_templ.go create mode 100644 internal/admin/ui/pages/login.templ create mode 100644 internal/admin/ui/pages/login_templ.go create mode 100644 internal/admin/ui/static/js/pages/login.js diff --git a/internal/admin/ui/loginlayout.templ b/internal/admin/ui/loginlayout.templ new file mode 100644 index 00000000..8214fd4d --- /dev/null +++ b/internal/admin/ui/loginlayout.templ @@ -0,0 +1,63 @@ +package ui + +templ LoginLayout(pageScript string, content templ.Component) { + + + + + + Anna Admin - Login + if pageScript != "" { + + } + + + + + + + + + + + + + @content + + @AlpineInit() + + +} diff --git a/internal/admin/ui/loginlayout_templ.go b/internal/admin/ui/loginlayout_templ.go new file mode 100644 index 00000000..9aec2cf7 --- /dev/null +++ b/internal/admin/ui/loginlayout_templ.go @@ -0,0 +1,79 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1001 +package ui + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +func LoginLayout(pageScript string, content templ.Component) templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "Anna Admin - Login") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if pageScript != "" { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = content.Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = AlpineInit().Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/internal/admin/ui/pages/login.templ b/internal/admin/ui/pages/login.templ new file mode 100644 index 00000000..b39ac4db --- /dev/null +++ b/internal/admin/ui/pages/login.templ @@ -0,0 +1,108 @@ +package pages + +templ LoginPage() { +
+
+
+ +
+ anna +

Admin Panel

+
+ +
+ +
+ +
+
+ + +
+
+ + +
+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +
+ +
+
+
+
+} diff --git a/internal/admin/ui/pages/login_templ.go b/internal/admin/ui/pages/login_templ.go new file mode 100644 index 00000000..87a65d60 --- /dev/null +++ b/internal/admin/ui/pages/login_templ.go @@ -0,0 +1,40 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1001 +package pages + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +func LoginPage() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
anna

Admin Panel

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/internal/admin/ui/static/js/pages/login.js b/internal/admin/ui/static/js/pages/login.js new file mode 100644 index 00000000..af1ccd11 --- /dev/null +++ b/internal/admin/ui/static/js/pages/login.js @@ -0,0 +1,80 @@ +/** + * Registers the loginPage Alpine.data component. + * + * @param {import('alpinejs').Alpine} Alpine + */ +export function register(Alpine) { + Alpine.data('loginPage', () => ({ + isRegister: false, + username: '', + password: '', + confirmPassword: '', + error: '', + loading: false, + + toggleMode() { + this.isRegister = !this.isRegister + this.error = '' + this.password = '' + this.confirmPassword = '' + }, + + async login() { + this.error = '' + this.loading = true + try { + const res = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: this.username, + password: this.password, + }), + }) + const json = await res.json() + if (json.error) { + this.error = json.error + return + } + window.location.href = '/' + } catch (e) { + this.error = e.message || 'Login failed' + } finally { + this.loading = false + } + }, + + async register() { + this.error = '' + if (this.password !== this.confirmPassword) { + this.error = 'Passwords do not match' + return + } + if (this.password.length < 8) { + this.error = 'Password must be at least 8 characters' + return + } + this.loading = true + try { + const res = await fetch('/api/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username: this.username, + password: this.password, + }), + }) + const json = await res.json() + if (json.error) { + this.error = json.error + return + } + window.location.href = '/' + } catch (e) { + this.error = e.message || 'Registration failed' + } finally { + this.loading = false + } + }, + })) +} From da1b084109bbbc2a0e37b75057c9c0363c55b93b Mon Sep 17 00:00:00 2001 From: Vaayne Date: Fri, 20 Mar 2026 19:22:19 +0800 Subject: [PATCH 14/53] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20admin=20UI=20au?= =?UTF-8?q?thentication=20with=20session=20middleware?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - auth.go: register, login, logout, me API handlers - middleware.go: session-based auth middleware + admin-only guard - server.go: CORS hardening (configurable origin, credentials), auth middleware on all routes, admin-only route guards, role-based root redirect (unauth->login, user->agents, admin->providers) - navbar.templ: role-based nav items, username + logout button - layout.templ: pass username/isAdmin to navbar - render.go: extract auth info from context for page rendering - Updated callers in cmd/anna (gateway.go, onboard.go) to pass AuthStore and PolicyEngine to admin.New() --- cmd/anna/gateway.go | 9 +- cmd/anna/onboard.go | 18 ++- internal/admin/auth.go | 210 ++++++++++++++++++++++++++++++ internal/admin/middleware.go | 157 ++++++++++++++++++++++ internal/admin/render.go | 19 ++- internal/admin/server.go | 162 +++++++++++++++-------- internal/admin/ui/layout.templ | 4 +- internal/admin/ui/layout_templ.go | 4 +- internal/admin/ui/navbar.templ | 56 +++++--- internal/admin/ui/navbar_templ.go | 137 ++++++++++++------- 10 files changed, 648 insertions(+), 128 deletions(-) create mode 100644 internal/admin/auth.go create mode 100644 internal/admin/middleware.go diff --git a/cmd/anna/gateway.go b/cmd/anna/gateway.go index b0593007..878d9893 100644 --- a/cmd/anna/gateway.go +++ b/cmd/anna/gateway.go @@ -15,6 +15,8 @@ import ( ucli "github.com/urfave/cli/v2" "github.com/vaayne/anna/internal/admin" "github.com/vaayne/anna/internal/agent" + "github.com/vaayne/anna/internal/auth" + "github.com/vaayne/anna/internal/auth/authdb" "github.com/vaayne/anna/internal/channel" "github.com/vaayne/anna/internal/channel/feishu" "github.com/vaayne/anna/internal/channel/qq" @@ -61,7 +63,12 @@ func runGateway(ctx context.Context, s *setupResult, listFn channel.ModelListFun // Optionally start admin panel server. if adminPort > 0 { - adminSrv := admin.New(s.store, s.mem, s.db) + as := authdb.New(s.db) + engine, err := auth.NewEngine(gctx, as) + if err != nil { + return fmt.Errorf("create auth engine: %w", err) + } + adminSrv := admin.New(s.store, as, engine, s.mem, s.db) ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", adminPort)) if err != nil { return fmt.Errorf("admin listen: %w", err) diff --git a/cmd/anna/onboard.go b/cmd/anna/onboard.go index a2eb56bb..482bddd8 100644 --- a/cmd/anna/onboard.go +++ b/cmd/anna/onboard.go @@ -14,6 +14,8 @@ import ( ucli "github.com/urfave/cli/v2" "github.com/vaayne/anna/internal/admin" + "github.com/vaayne/anna/internal/auth" + "github.com/vaayne/anna/internal/auth/authdb" "github.com/vaayne/anna/internal/config" appdb "github.com/vaayne/anna/internal/db" "github.com/vaayne/anna/internal/memory" @@ -57,11 +59,21 @@ func runOnboard(ctx context.Context, port int) error { return fmt.Errorf("seed defaults: %w", err) } - // 4. Create memory engine (for session listing in admin panel). + // 4. Seed auth roles/policies and create policy engine. + as := authdb.New(db) + if err := auth.SeedRolesAndPolicies(ctx, as); err != nil { + return fmt.Errorf("seed auth: %w", err) + } + engine, err := auth.NewEngine(ctx, as) + if err != nil { + return fmt.Errorf("create auth engine: %w", err) + } + + // 5. Create memory engine (for session listing in admin panel). mem := memory.NewEngineFromDB(db, nil) - // 5. Create admin server. - srv := admin.New(store, mem, db) + // 6. Create admin server. + srv := admin.New(store, as, engine, mem, db) // 6. Listen and serve. ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) diff --git a/internal/admin/auth.go b/internal/admin/auth.go new file mode 100644 index 00000000..d81c610e --- /dev/null +++ b/internal/admin/auth.go @@ -0,0 +1,210 @@ +package admin + +import ( + "net/http" + "strings" + "time" + + "github.com/vaayne/anna/internal/auth" +) + +// registerHandler handles POST /api/auth/register. +func (s *Server) registerHandler(w http.ResponseWriter, r *http.Request) { + var body struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := decodeJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + + body.Username = strings.TrimSpace(body.Username) + if body.Username == "" { + writeError(w, http.StatusBadRequest, "username is required") + return + } + if len(body.Password) < 8 { + writeError(w, http.StatusBadRequest, "password must be at least 8 characters") + return + } + + ctx := r.Context() + + // Hash password. + hash, err := auth.HashPassword(body.Password) + if err != nil { + s.log.Error("hash password", "error", err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + + // Create user. + user, err := s.authStore.CreateUser(ctx, body.Username, hash) + if err != nil { + if strings.Contains(err.Error(), "UNIQUE constraint") { + writeError(w, http.StatusConflict, "username already taken") + return + } + s.log.Error("create user", "error", err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + + // If first user, assign admin role; otherwise assign user role. + count, err := s.authStore.CountUsers(ctx) + if err != nil { + s.log.Error("count users", "error", err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + + if count == 1 { + _ = s.authStore.AssignRole(ctx, user.ID, auth.RoleAdmin) + } + _ = s.authStore.AssignRole(ctx, user.ID, auth.RoleUser) + + // Create session. + sessionID := auth.NewSessionID() + _, err = s.authStore.CreateSession(ctx, auth.Session{ + ID: sessionID, + UserID: user.ID, + ExpiresAt: time.Now().Add(auth.SessionDuration), + }) + if err != nil { + s.log.Error("create session", "error", err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + + secure := !isLocalhost(r) + auth.SetSessionCookie(w, sessionID, secure) + + writeData(w, http.StatusCreated, map[string]any{ + "id": user.ID, + "username": user.Username, + }) +} + +// loginHandler handles POST /api/auth/login. +func (s *Server) loginHandler(w http.ResponseWriter, r *http.Request) { + var body struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := decodeJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + + body.Username = strings.TrimSpace(body.Username) + + // Rate limit by IP. + ip := clientIP(r) + if err := s.rateLimiter.CheckIP(ip); err != nil { + writeError(w, http.StatusTooManyRequests, err.Error()) + return + } + + // Rate limit by username. + if body.Username != "" { + if err := s.rateLimiter.CheckUsername(body.Username); err != nil { + writeError(w, http.StatusTooManyRequests, err.Error()) + return + } + } + + ctx := r.Context() + + user, err := s.authStore.GetUserByUsername(ctx, body.Username) + if err != nil { + s.rateLimiter.RecordLoginFailure(body.Username) + writeError(w, http.StatusUnauthorized, "invalid username or password") + return + } + + if err := auth.CheckPassword(user.PasswordHash, body.Password); err != nil { + s.rateLimiter.RecordLoginFailure(body.Username) + writeError(w, http.StatusUnauthorized, "invalid username or password") + return + } + + if !user.IsActive { + writeError(w, http.StatusForbidden, "account is deactivated") + return + } + + s.rateLimiter.RecordLoginSuccess(body.Username) + + // Create session. + sessionID := auth.NewSessionID() + _, err = s.authStore.CreateSession(ctx, auth.Session{ + ID: sessionID, + UserID: user.ID, + ExpiresAt: time.Now().Add(auth.SessionDuration), + }) + if err != nil { + s.log.Error("create session", "error", err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + + secure := !isLocalhost(r) + auth.SetSessionCookie(w, sessionID, secure) + + writeData(w, http.StatusOK, map[string]any{ + "id": user.ID, + "username": user.Username, + }) +} + +// logoutHandler handles POST /api/auth/logout. +func (s *Server) logoutHandler(w http.ResponseWriter, r *http.Request) { + sessionID, err := auth.GetSessionCookie(r) + if err == nil { + _ = s.authStore.DeleteSession(r.Context(), sessionID) + } + auth.ClearSessionCookie(w) + writeData(w, http.StatusOK, map[string]string{"status": "logged out"}) +} + +// meHandler handles GET /api/auth/me. +func (s *Server) meHandler(w http.ResponseWriter, r *http.Request) { + info := UserFromContext(r.Context()) + if info == nil { + writeError(w, http.StatusUnauthorized, "not authenticated") + return + } + writeData(w, http.StatusOK, map[string]any{ + "id": info.UserID, + "username": info.Username, + "roles": info.Roles, + "is_admin": info.IsAdmin, + }) +} + +// isLocalhost returns true if the request host is localhost or 127.0.0.1. +func isLocalhost(r *http.Request) bool { + host := r.Host + return strings.HasPrefix(host, "localhost") || + strings.HasPrefix(host, "127.0.0.1") || + strings.HasPrefix(host, "[::1]") +} + +// clientIP extracts the client IP from the request, checking X-Forwarded-For +// and X-Real-IP headers first. +func clientIP(r *http.Request) string { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + parts := strings.SplitN(xff, ",", 2) + return strings.TrimSpace(parts[0]) + } + if xri := r.Header.Get("X-Real-IP"); xri != "" { + return strings.TrimSpace(xri) + } + // Strip port from RemoteAddr. + ip := r.RemoteAddr + if idx := strings.LastIndex(ip, ":"); idx != -1 { + ip = ip[:idx] + } + return ip +} diff --git a/internal/admin/middleware.go b/internal/admin/middleware.go new file mode 100644 index 00000000..c19cad44 --- /dev/null +++ b/internal/admin/middleware.go @@ -0,0 +1,157 @@ +package admin + +import ( + "context" + "net/http" + "strings" + "time" + + "github.com/vaayne/anna/internal/auth" +) + +// contextKey is used for storing auth info in request context. +type contextKey string + +const authInfoKey contextKey = "authInfo" + +// AuthInfo carries authenticated user data through request context. +type AuthInfo struct { + UserID int64 `json:"user_id"` + Username string `json:"username"` + Roles []string `json:"roles"` + IsAdmin bool `json:"is_admin"` +} + +// UserFromContext extracts the AuthInfo from a request context. +// Returns nil if the user is not authenticated. +func UserFromContext(ctx context.Context) *AuthInfo { + info, _ := ctx.Value(authInfoKey).(*AuthInfo) + return info +} + +// withAuthInfo sets the AuthInfo in the request context. +func withAuthInfo(ctx context.Context, info *AuthInfo) context.Context { + return context.WithValue(ctx, authInfoKey, info) +} + +// authMiddleware validates the session cookie, loads the user and roles, +// and injects AuthInfo into the request context. Unauthenticated requests +// to API routes get 401; page routes get redirected to /login. +func (s *Server) authMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := r.URL.Path + + // Exempt paths: login page, static assets, auth login/register/logout. + if path == "/login" || + strings.HasPrefix(path, "/static/") || + path == "/api/auth/login" || + path == "/api/auth/register" || + path == "/api/auth/logout" { + next.ServeHTTP(w, r) + return + } + + // Try to load session. + sessionID, err := auth.GetSessionCookie(r) + if err != nil { + s.denyAccess(w, r) + return + } + + ctx := r.Context() + + // Clean up expired sessions lazily. + _ = s.authStore.DeleteExpiredSessions(ctx) + + session, err := s.authStore.GetSession(ctx, sessionID) + if err != nil { + auth.ClearSessionCookie(w) + s.denyAccess(w, r) + return + } + + // Check expiry. + if time.Now().After(session.ExpiresAt) { + _ = s.authStore.DeleteSession(ctx, sessionID) + auth.ClearSessionCookie(w) + s.denyAccess(w, r) + return + } + + // Extend session expiry on each authenticated request. + _ = s.authStore.UpdateSessionExpiry(ctx, sessionID, time.Now().Add(auth.SessionDuration)) + + // Load user. + user, err := s.authStore.GetUser(ctx, session.UserID) + if err != nil { + _ = s.authStore.DeleteSession(ctx, sessionID) + auth.ClearSessionCookie(w) + s.denyAccess(w, r) + return + } + + if !user.IsActive { + _ = s.authStore.DeleteSession(ctx, sessionID) + auth.ClearSessionCookie(w) + s.denyAccess(w, r) + return + } + + // Load roles. + roles, err := s.authStore.ListUserRoles(ctx, user.ID) + if err != nil { + s.log.Error("load user roles", "user_id", user.ID, "error", err) + s.denyAccess(w, r) + return + } + + roleIDs := make([]string, len(roles)) + isAdmin := false + for i, role := range roles { + roleIDs[i] = role.ID + if role.ID == auth.RoleAdmin { + isAdmin = true + } + } + + info := &AuthInfo{ + UserID: user.ID, + Username: user.Username, + Roles: roleIDs, + IsAdmin: isAdmin, + } + + next.ServeHTTP(w, r.WithContext(withAuthInfo(ctx, info))) + }) +} + +// adminOnlyMiddleware checks that the authenticated user has the admin role. +// Returns 403 for non-admin users. +func (s *Server) adminOnlyMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + info := UserFromContext(r.Context()) + if info == nil || !info.IsAdmin { + if isAPIRoute(r.URL.Path) { + writeError(w, http.StatusForbidden, "admin access required") + } else { + http.Redirect(w, r, "/agents", http.StatusFound) + } + return + } + next.ServeHTTP(w, r) + }) +} + +// denyAccess returns 401 for API routes or redirects to /login for page routes. +func (s *Server) denyAccess(w http.ResponseWriter, r *http.Request) { + if isAPIRoute(r.URL.Path) { + writeError(w, http.StatusUnauthorized, "authentication required") + } else { + http.Redirect(w, r, "/login", http.StatusFound) + } +} + +// isAPIRoute returns true if the path starts with /api/. +func isAPIRoute(path string) bool { + return strings.HasPrefix(path, "/api/") +} diff --git a/internal/admin/render.go b/internal/admin/render.go index 264a63bb..af85918f 100644 --- a/internal/admin/render.go +++ b/internal/admin/render.go @@ -8,6 +8,13 @@ import ( "github.com/vaayne/anna/internal/admin/ui/pages" ) +func (s *Server) pageLogin(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := ui.LoginLayout("/static/js/pages/login.js", pages.LoginPage()).Render(r.Context(), w); err != nil { + s.log.Error("render page", "page", "login", "error", err) + } +} + func (s *Server) pageProviders(w http.ResponseWriter, r *http.Request) { s.renderPage(w, r, "providers", "/static/js/pages/providers.js", pages.ProvidersPage()) } @@ -37,10 +44,18 @@ func (s *Server) pageSettings(w http.ResponseWriter, r *http.Request) { } // renderPage sets the HTML content type and renders the layout with the -// given page content. +// given page content. Auth info is extracted from context for the navbar. func (s *Server) renderPage(w http.ResponseWriter, r *http.Request, activePage, pageScript string, content templ.Component) { w.Header().Set("Content-Type", "text/html; charset=utf-8") - if err := ui.Layout(activePage, pageScript, content).Render(r.Context(), w); err != nil { + + username := "" + isAdmin := false + if info := UserFromContext(r.Context()); info != nil { + username = info.Username + isAdmin = info.IsAdmin + } + + if err := ui.Layout(activePage, pageScript, username, isAdmin, content).Render(r.Context(), w); err != nil { s.log.Error("render page", "page", activePage, "error", err) } } diff --git a/internal/admin/server.go b/internal/admin/server.go index 97c557b6..e0e4d3d8 100644 --- a/internal/admin/server.go +++ b/internal/admin/server.go @@ -1,11 +1,13 @@ package admin import ( + "context" "database/sql" "encoding/json" "log/slog" "net/http" + "github.com/vaayne/anna/internal/auth" "github.com/vaayne/anna/internal/config" "github.com/vaayne/anna/internal/db/sqlc" "github.com/vaayne/anna/internal/memory" @@ -13,47 +15,68 @@ import ( // Server provides HTTP handlers for the admin API and templ-rendered pages. type Server struct { - store config.Store - mem memory.Engine - db *sql.DB - q *sqlc.Queries - mux *http.ServeMux - log *slog.Logger + store config.Store + authStore auth.AuthStore + engine *auth.PolicyEngine + rateLimiter *auth.RateLimiter + mem memory.Engine + db *sql.DB + q *sqlc.Queries + mux *http.ServeMux + log *slog.Logger } // New creates an admin server with all API routes mounted. -func New(store config.Store, mem memory.Engine, db *sql.DB) *Server { +func New(store config.Store, authStore auth.AuthStore, engine *auth.PolicyEngine, mem memory.Engine, db *sql.DB) *Server { s := &Server{ - store: store, - mem: mem, - db: db, - q: sqlc.New(db), - mux: http.NewServeMux(), - log: slog.With("component", "admin"), + store: store, + authStore: authStore, + engine: engine, + rateLimiter: auth.NewRateLimiter(), + mem: mem, + db: db, + q: sqlc.New(db), + mux: http.NewServeMux(), + log: slog.With("component", "admin"), } // Serve static assets (JS modules). s.mux.Handle("GET /static/", staticHandler()) + // Login page (exempt from auth). + s.mux.HandleFunc("GET /login", s.pageLogin) + + // Auth API routes (exempt from auth). + s.mux.HandleFunc("POST /api/auth/register", s.registerHandler) + s.mux.HandleFunc("POST /api/auth/login", s.loginHandler) + s.mux.HandleFunc("POST /api/auth/logout", s.logoutHandler) + s.mux.HandleFunc("GET /api/auth/me", s.meHandler) + // Page routes — templ-rendered HTML pages. - s.mux.HandleFunc("GET /providers", s.pageProviders) + // Admin-only pages: providers, channels, users, scheduler, settings. + s.mux.Handle("GET /providers", s.adminOnlyMiddleware(http.HandlerFunc(s.pageProviders))) s.mux.HandleFunc("GET /agents", s.pageAgents) - s.mux.HandleFunc("GET /channels", s.pageChannels) - s.mux.HandleFunc("GET /users", s.pageUsers) + s.mux.Handle("GET /channels", s.adminOnlyMiddleware(http.HandlerFunc(s.pageChannels))) + s.mux.Handle("GET /users", s.adminOnlyMiddleware(http.HandlerFunc(s.pageUsers))) s.mux.HandleFunc("GET /sessions", s.pageSessions) - s.mux.HandleFunc("GET /scheduler", s.pageScheduler) - s.mux.HandleFunc("GET /settings", s.pageSettings) + s.mux.Handle("GET /scheduler", s.adminOnlyMiddleware(http.HandlerFunc(s.pageScheduler))) + s.mux.Handle("GET /settings", s.adminOnlyMiddleware(http.HandlerFunc(s.pageSettings))) - // Root redirects to /providers. - s.mux.HandleFunc("GET /{$}", s.redirectToProviders) + // Root redirect based on auth status. + s.mux.HandleFunc("GET /{$}", s.redirectRoot) + + // Admin-only API routes. + adminAPI := func(handler http.HandlerFunc) http.Handler { + return s.adminOnlyMiddleware(handler) + } - // Provider APIs. - s.mux.HandleFunc("GET /api/providers", s.listProviders) - s.mux.HandleFunc("POST /api/providers", s.createProvider) - s.mux.HandleFunc("GET /api/providers/{id}", s.getProvider) - s.mux.HandleFunc("PUT /api/providers/{id}", s.updateProvider) - s.mux.HandleFunc("DELETE /api/providers/{id}", s.deleteProvider) - s.mux.HandleFunc("POST /api/providers/{id}/models", s.fetchProviderModels) + // Provider APIs (admin-only). + s.mux.Handle("GET /api/providers", adminAPI(s.listProviders)) + s.mux.Handle("POST /api/providers", adminAPI(s.createProvider)) + s.mux.Handle("GET /api/providers/{id}", adminAPI(s.getProvider)) + s.mux.Handle("PUT /api/providers/{id}", adminAPI(s.updateProvider)) + s.mux.Handle("DELETE /api/providers/{id}", adminAPI(s.deleteProvider)) + s.mux.Handle("POST /api/providers/{id}/models", adminAPI(s.fetchProviderModels)) // Agent APIs. s.mux.HandleFunc("GET /api/agents", s.listAgents) @@ -62,17 +85,17 @@ func New(store config.Store, mem memory.Engine, db *sql.DB) *Server { s.mux.HandleFunc("PUT /api/agents/{id}", s.updateAgent) s.mux.HandleFunc("DELETE /api/agents/{id}", s.deleteAgent) - // Channel APIs. - s.mux.HandleFunc("GET /api/channels", s.listChannels) - s.mux.HandleFunc("GET /api/channels/{platform}", s.getChannel) - s.mux.HandleFunc("PUT /api/channels/{platform}", s.updateChannel) + // Channel APIs (admin-only). + s.mux.Handle("GET /api/channels", adminAPI(s.listChannels)) + s.mux.Handle("GET /api/channels/{platform}", adminAPI(s.getChannel)) + s.mux.Handle("PUT /api/channels/{platform}", adminAPI(s.updateChannel)) - // User APIs. - s.mux.HandleFunc("GET /api/users", s.listUsers) - s.mux.HandleFunc("PUT /api/users/{id}", s.updateUser) - s.mux.HandleFunc("GET /api/users/{id}/memories", s.listUserMemories) - s.mux.HandleFunc("PUT /api/users/{id}/memories/{agentId}", s.setUserMemory) - s.mux.HandleFunc("DELETE /api/users/{id}/memories/{agentId}", s.deleteUserMemory) + // User APIs (admin-only). + s.mux.Handle("GET /api/users", adminAPI(s.listUsers)) + s.mux.Handle("PUT /api/users/{id}", adminAPI(s.updateUser)) + s.mux.Handle("GET /api/users/{id}/memories", adminAPI(s.listUserMemories)) + s.mux.Handle("PUT /api/users/{id}/memories/{agentId}", adminAPI(s.setUserMemory)) + s.mux.Handle("DELETE /api/users/{id}/memories/{agentId}", adminAPI(s.deleteUserMemory)) // Session APIs. s.mux.HandleFunc("GET /api/sessions", s.listSessions) @@ -80,9 +103,9 @@ func New(store config.Store, mem memory.Engine, db *sql.DB) *Server { s.mux.HandleFunc("GET /api/sessions/{sessionID}/messages", s.getSessionMessages) s.mux.HandleFunc("GET /api/sessions/{sessionID}/system-prompt", s.getSessionSystemPrompt) - // Settings APIs. - s.mux.HandleFunc("GET /api/settings/{key}", s.getSetting) - s.mux.HandleFunc("PUT /api/settings/{key}", s.updateSetting) + // Settings APIs (admin-only). + s.mux.Handle("GET /api/settings/{key}", adminAPI(s.getSetting)) + s.mux.Handle("PUT /api/settings/{key}", adminAPI(s.updateSetting)) // Models API (cached models, no live provider calls). s.mux.HandleFunc("GET /api/models", s.listCachedModels) @@ -90,43 +113,70 @@ func New(store config.Store, mem memory.Engine, db *sql.DB) *Server { // Tools API (available tools for agents). s.mux.HandleFunc("GET /api/tools", s.listAgentTools) - // Scheduler job APIs. - s.mux.HandleFunc("GET /api/scheduler/jobs", s.listSchedulerJobs) - s.mux.HandleFunc("POST /api/scheduler/jobs", s.createSchedulerJob) - s.mux.HandleFunc("PUT /api/scheduler/jobs/{id}", s.updateSchedulerJob) - s.mux.HandleFunc("DELETE /api/scheduler/jobs/{id}", s.deleteSchedulerJob) + // Scheduler job APIs (admin-only). + s.mux.Handle("GET /api/scheduler/jobs", adminAPI(s.listSchedulerJobs)) + s.mux.Handle("POST /api/scheduler/jobs", adminAPI(s.createSchedulerJob)) + s.mux.Handle("PUT /api/scheduler/jobs/{id}", adminAPI(s.updateSchedulerJob)) + s.mux.Handle("DELETE /api/scheduler/jobs/{id}", adminAPI(s.deleteSchedulerJob)) return s } -// redirectToProviders sends a 302 redirect to /providers. -func (s *Server) redirectToProviders(w http.ResponseWriter, r *http.Request) { - http.Redirect(w, r, "/providers", http.StatusFound) +// redirectRoot sends unauthenticated users to /login, admins to /providers, +// and regular users to /agents. +func (s *Server) redirectRoot(w http.ResponseWriter, r *http.Request) { + info := UserFromContext(r.Context()) + if info == nil { + http.Redirect(w, r, "/login", http.StatusFound) + return + } + if info.IsAdmin { + http.Redirect(w, r, "/providers", http.StatusFound) + return + } + http.Redirect(w, r, "/agents", http.StatusFound) } -// Handler returns the HTTP handler with CORS and JSON middleware applied. +// Handler returns the HTTP handler with CORS, JSON, and auth middleware applied. func (s *Server) Handler() http.Handler { - return s.withMiddleware(s.mux) + return s.corsMiddleware(s.authMiddleware(s.jsonMiddleware(s.mux))) } -// withMiddleware wraps the mux with CORS and JSON content-type headers. -func (s *Server) withMiddleware(next http.Handler) http.Handler { +// corsMiddleware handles CORS headers. Origin is configurable via the +// settings table key "admin.cors_origin"; defaults to http://localhost:8080. +func (s *Server) corsMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // CORS for local dev. - w.Header().Set("Access-Control-Allow-Origin", "*") + origin := s.corsOrigin() + w.Header().Set("Access-Control-Allow-Origin", origin) w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + w.Header().Set("Access-Control-Allow-Credentials", "true") if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } - // JSON content-type for /api/ routes. + next.ServeHTTP(w, r) + }) +} + +// corsOrigin reads the CORS origin from the settings table. Falls back to +// http://localhost:8080 if not configured. +func (s *Server) corsOrigin() string { + val, err := s.store.GetSetting(context.Background(), "admin.cors_origin") + if err == nil && val != "" { + return val + } + return "http://localhost:8080" +} + +// jsonMiddleware sets JSON content-type for /api/ routes. +func (s *Server) jsonMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if len(r.URL.Path) >= 5 && r.URL.Path[:5] == "/api/" { w.Header().Set("Content-Type", "application/json") } - next.ServeHTTP(w, r) }) } diff --git a/internal/admin/ui/layout.templ b/internal/admin/ui/layout.templ index 49fb5a6b..a0f63871 100644 --- a/internal/admin/ui/layout.templ +++ b/internal/admin/ui/layout.templ @@ -1,6 +1,6 @@ package ui -templ Layout(activePage string, pageScript string, content templ.Component) { +templ Layout(activePage string, pageScript string, username string, isAdmin bool, content templ.Component) { @@ -55,7 +55,7 @@ templ Layout(activePage string, pageScript string, content templ.Component) { - @Navbar(activePage) + @Navbar(activePage, username, isAdmin)
- anna + if isAdmin { + anna + } else { + anna + }
- +
- - - - Connected - + + if username != "" { + { username } +
+ +
+ } ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } From 677142d41228815ea7b45f3824cf895ef62655ce Mon Sep 17 00:00:00 2001 From: Vaayne Date: Fri, 20 Mar 2026 19:22:27 +0800 Subject: [PATCH 15/53] =?UTF-8?q?=E2=9C=85=20test:=20add=20auth=20handler?= =?UTF-8?q?=20and=20middleware=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - auth_test.go: register, login, logout, /me, password validation, duplicate username, wrong password, first-user admin role, expired session denial - server_test.go: updated for auth-aware server (session cookies, admin/non-admin access control, unauthenticated redirects) --- internal/admin/auth_test.go | 233 ++++++++++++++++++++++++++++++++++ internal/admin/server_test.go | 200 +++++++++++++++++++++++++---- 2 files changed, 409 insertions(+), 24 deletions(-) create mode 100644 internal/admin/auth_test.go diff --git a/internal/admin/auth_test.go b/internal/admin/auth_test.go new file mode 100644 index 00000000..7dc486fe --- /dev/null +++ b/internal/admin/auth_test.go @@ -0,0 +1,233 @@ +package admin_test + +import ( + "context" + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/vaayne/anna/internal/auth" +) + +func TestRegisterAndLogin(t *testing.T) { + env := setupAdmin(t) + + // Register a new user. + body := map[string]string{ + "username": "newuser", + "password": "securepass123", + } + rr := doUnauthRequest(t, env.srv, "POST", "/api/auth/register", body) + if rr.Code != http.StatusCreated { + t.Fatalf("register status = %d, want %d (body: %s)", rr.Code, http.StatusCreated, rr.Body.String()) + } + + // Should have a session cookie. + var sessionCookie *http.Cookie + for _, c := range rr.Result().Cookies() { + if c.Name == auth.SessionCookieName { + sessionCookie = c + break + } + } + if sessionCookie == nil { + t.Fatal("register should set session cookie") + } + + // Login with the same credentials. + body = map[string]string{ + "username": "newuser", + "password": "securepass123", + } + rr = doUnauthRequest(t, env.srv, "POST", "/api/auth/login", body) + if rr.Code != http.StatusOK { + t.Fatalf("login status = %d, want %d (body: %s)", rr.Code, http.StatusOK, rr.Body.String()) + } + + sessionCookie = nil + for _, c := range rr.Result().Cookies() { + if c.Name == auth.SessionCookieName { + sessionCookie = c + break + } + } + if sessionCookie == nil { + t.Fatal("login should set session cookie") + } +} + +func TestRegisterShortPassword(t *testing.T) { + env := setupAdmin(t) + + body := map[string]string{ + "username": "shortpw", + "password": "short", + } + rr := doUnauthRequest(t, env.srv, "POST", "/api/auth/register", body) + if rr.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusBadRequest) + } +} + +func TestRegisterDuplicateUsername(t *testing.T) { + env := setupAdmin(t) + + body := map[string]string{ + "username": "dupuser", + "password": "password123", + } + rr := doUnauthRequest(t, env.srv, "POST", "/api/auth/register", body) + if rr.Code != http.StatusCreated { + t.Fatalf("first register status = %d, want %d", rr.Code, http.StatusCreated) + } + + rr = doUnauthRequest(t, env.srv, "POST", "/api/auth/register", body) + if rr.Code != http.StatusConflict { + t.Fatalf("duplicate register status = %d, want %d", rr.Code, http.StatusConflict) + } +} + +func TestLoginWrongPassword(t *testing.T) { + env := setupAdmin(t) + + body := map[string]string{ + "username": "testadmin", + "password": "wrongpassword", + } + rr := doUnauthRequest(t, env.srv, "POST", "/api/auth/login", body) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusUnauthorized) + } +} + +func TestLoginNonexistentUser(t *testing.T) { + env := setupAdmin(t) + + body := map[string]string{ + "username": "noexist", + "password": "anypassword", + } + rr := doUnauthRequest(t, env.srv, "POST", "/api/auth/login", body) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusUnauthorized) + } +} + +func TestLogout(t *testing.T) { + env := setupAdmin(t) + + rr := doRequest(t, env, "POST", "/api/auth/logout", nil) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK) + } + + // Session cookie should be cleared. + var cleared bool + for _, c := range rr.Result().Cookies() { + if c.Name == auth.SessionCookieName && c.MaxAge == -1 { + cleared = true + } + } + if !cleared { + t.Error("logout should clear session cookie") + } +} + +func TestMeEndpoint(t *testing.T) { + env := setupAdmin(t) + + rr := doRequest(t, env, "GET", "/api/auth/me", nil) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK) + } + + resp := parseResponse(t, rr) + var me struct { + ID int64 `json:"id"` + Username string `json:"username"` + Roles []string `json:"roles"` + IsAdmin bool `json:"is_admin"` + } + if err := json.Unmarshal(resp.Data, &me); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if me.Username != "testadmin" { + t.Errorf("username = %q, want %q", me.Username, "testadmin") + } + if !me.IsAdmin { + t.Error("expected is_admin = true") + } +} + +func TestMeUnauthenticated(t *testing.T) { + env := setupAdmin(t) + + rr := doUnauthRequest(t, env.srv, "GET", "/api/auth/me", nil) + // /api/auth/me is exempt from the auth middleware (it's under /api/auth/) + // but the meHandler checks UserFromContext and returns 401. + if rr.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusUnauthorized) + } +} + +func TestFirstUserGetsAdminRole(t *testing.T) { + env := setupAdmin(t) + + // The "testadmin" user already exists from setupAdmin. Create a fresh env + // to test first-user logic: register a new user as the "second" user. + body := map[string]string{ + "username": "seconduser", + "password": "password123", + } + rr := doUnauthRequest(t, env.srv, "POST", "/api/auth/register", body) + if rr.Code != http.StatusCreated { + t.Fatalf("register status = %d, want %d (body: %s)", rr.Code, http.StatusCreated, rr.Body.String()) + } + + // Check the second user's roles: should have "user" but not "admin". + user, err := env.authStore.GetUserByUsername(context.Background(), "seconduser") + if err != nil { + t.Fatalf("GetUserByUsername: %v", err) + } + roles, err := env.authStore.ListUserRoles(context.Background(), user.ID) + if err != nil { + t.Fatalf("ListUserRoles: %v", err) + } + hasAdmin := false + hasUser := false + for _, r := range roles { + if r.ID == auth.RoleAdmin { + hasAdmin = true + } + if r.ID == auth.RoleUser { + hasUser = true + } + } + if hasAdmin { + t.Error("second user should not have admin role") + } + if !hasUser { + t.Error("second user should have user role") + } +} + +func TestExpiredSessionDenied(t *testing.T) { + env := setupAdmin(t) + + // Create a session that is already expired. + expiredID := auth.NewSessionID() + _, err := env.authStore.CreateSession(context.Background(), auth.Session{ + ID: expiredID, + UserID: env.adminUser.ID, + ExpiresAt: time.Now().Add(-time.Hour), + }) + if err != nil { + t.Fatalf("CreateSession: %v", err) + } + + rr := doRequestWithSession(t, env.srv, expiredID, "GET", "/api/agents", nil) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusUnauthorized) + } +} diff --git a/internal/admin/server_test.go b/internal/admin/server_test.go index 91f7a7e2..17558320 100644 --- a/internal/admin/server_test.go +++ b/internal/admin/server_test.go @@ -9,14 +9,24 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/vaayne/anna/internal/admin" + "github.com/vaayne/anna/internal/auth" + "github.com/vaayne/anna/internal/auth/authdb" "github.com/vaayne/anna/internal/config" appdb "github.com/vaayne/anna/internal/db" "github.com/vaayne/anna/internal/memory" ) -func setupAdmin(t *testing.T) *admin.Server { +type testEnv struct { + srv *admin.Server + authStore auth.AuthStore + adminUser auth.AuthUser + sessionID string +} + +func setupAdmin(t *testing.T) *testEnv { t.Helper() dbPath := filepath.Join(t.TempDir(), "test.db") db, err := appdb.OpenDB(dbPath) @@ -30,11 +40,52 @@ func setupAdmin(t *testing.T) *admin.Server { t.Fatalf("SeedDefaults: %v", err) } + as := authdb.New(db) + if err := auth.SeedRolesAndPolicies(context.Background(), as); err != nil { + t.Fatalf("SeedRolesAndPolicies: %v", err) + } + + engine, err := auth.NewEngine(context.Background(), as) + if err != nil { + t.Fatalf("NewEngine: %v", err) + } + mem := memory.NewEngineFromDB(db, nil) - return admin.New(store, mem, db) + srv := admin.New(store, as, engine, mem, db) + + // Create an admin user for authenticated requests. + hash, _ := auth.HashPassword("testpassword") + user, err := as.CreateUser(context.Background(), "testadmin", hash) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + _ = as.AssignRole(context.Background(), user.ID, auth.RoleAdmin) + _ = as.AssignRole(context.Background(), user.ID, auth.RoleUser) + + sessionID := auth.NewSessionID() + _, err = as.CreateSession(context.Background(), auth.Session{ + ID: sessionID, + UserID: user.ID, + ExpiresAt: time.Now().Add(auth.SessionDuration), + }) + if err != nil { + t.Fatalf("CreateSession: %v", err) + } + + return &testEnv{ + srv: srv, + authStore: as, + adminUser: user, + sessionID: sessionID, + } +} + +func doRequest(t *testing.T, env *testEnv, method, path string, body any) *httptest.ResponseRecorder { + t.Helper() + return doRequestWithSession(t, env.srv, env.sessionID, method, path, body) } -func doRequest(t *testing.T, srv *admin.Server, method, path string, body any) *httptest.ResponseRecorder { +func doRequestWithSession(t *testing.T, srv *admin.Server, sessionID, method, path string, body any) *httptest.ResponseRecorder { t.Helper() var buf bytes.Buffer if body != nil { @@ -46,11 +97,19 @@ func doRequest(t *testing.T, srv *admin.Server, method, path string, body any) * if body != nil { req.Header.Set("Content-Type", "application/json") } + if sessionID != "" { + req.AddCookie(&http.Cookie{Name: auth.SessionCookieName, Value: sessionID}) + } rr := httptest.NewRecorder() srv.Handler().ServeHTTP(rr, req) return rr } +func doUnauthRequest(t *testing.T, srv *admin.Server, method, path string, body any) *httptest.ResponseRecorder { + t.Helper() + return doRequestWithSession(t, srv, "", method, path, body) +} + type apiResponse struct { Data json.RawMessage `json:"data"` Error string `json:"error"` @@ -66,9 +125,9 @@ func parseResponse(t *testing.T, rr *httptest.ResponseRecorder) apiResponse { } func TestListProviders(t *testing.T) { - srv := setupAdmin(t) + env := setupAdmin(t) - rr := doRequest(t, srv, "GET", "/api/providers", nil) + rr := doRequest(t, env, "GET", "/api/providers", nil) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK) } @@ -78,7 +137,6 @@ func TestListProviders(t *testing.T) { if err := json.Unmarshal(resp.Data, &providers); err != nil { t.Fatalf("unmarshal: %v", err) } - // SeedDefaults creates "anthropic" provider. if len(providers) == 0 { t.Fatal("expected at least one provider") } @@ -88,20 +146,20 @@ func TestListProviders(t *testing.T) { } func TestCreateProvider(t *testing.T) { - srv := setupAdmin(t) + env := setupAdmin(t) body := map[string]any{ "id": "openai", "name": "OpenAI", "api_key": "sk-test", } - rr := doRequest(t, srv, "POST", "/api/providers", body) + rr := doRequest(t, env, "POST", "/api/providers", body) if rr.Code != http.StatusCreated { t.Fatalf("status = %d, want %d (body: %s)", rr.Code, http.StatusCreated, rr.Body.String()) } // Verify it appears in list. - rr = doRequest(t, srv, "GET", "/api/providers", nil) + rr = doRequest(t, env, "GET", "/api/providers", nil) resp := parseResponse(t, rr) var providers []config.Provider _ = json.Unmarshal(resp.Data, &providers) @@ -117,9 +175,9 @@ func TestCreateProvider(t *testing.T) { } func TestListAgents(t *testing.T) { - srv := setupAdmin(t) + env := setupAdmin(t) - rr := doRequest(t, srv, "GET", "/api/agents", nil) + rr := doRequest(t, env, "GET", "/api/agents", nil) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK) } @@ -138,7 +196,7 @@ func TestListAgents(t *testing.T) { } func TestCreateAgent(t *testing.T) { - srv := setupAdmin(t) + env := setupAdmin(t) body := config.Agent{ ID: "coder", @@ -148,22 +206,23 @@ func TestCreateAgent(t *testing.T) { Workspace: "/tmp/coder", Enabled: true, } - rr := doRequest(t, srv, "POST", "/api/agents", body) + rr := doRequest(t, env, "POST", "/api/agents", body) if rr.Code != http.StatusCreated { t.Fatalf("status = %d, want %d (body: %s)", rr.Code, http.StatusCreated, rr.Body.String()) } // Verify via get. - rr = doRequest(t, srv, "GET", "/api/agents/coder", nil) + rr = doRequest(t, env, "GET", "/api/agents/coder", nil) if rr.Code != http.StatusOK { t.Fatalf("get status = %d, want %d", rr.Code, http.StatusOK) } } func TestRootRedirect(t *testing.T) { - srv := setupAdmin(t) + env := setupAdmin(t) - rr := doRequest(t, srv, "GET", "/", nil) + // Authenticated admin -> /providers. + rr := doRequest(t, env, "GET", "/", nil) if rr.Code != http.StatusFound { t.Fatalf("status = %d, want %d", rr.Code, http.StatusFound) } @@ -171,10 +230,20 @@ func TestRootRedirect(t *testing.T) { if loc != "/providers" { t.Errorf("Location = %q, want %q", loc, "/providers") } + + // Unauthenticated -> /login. + rr = doUnauthRequest(t, env.srv, "GET", "/", nil) + if rr.Code != http.StatusFound { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusFound) + } + loc = rr.Header().Get("Location") + if loc != "/login" { + t.Errorf("Location = %q, want %q", loc, "/login") + } } func TestPageRoutes(t *testing.T) { - srv := setupAdmin(t) + env := setupAdmin(t) pages := []string{ "/providers", "/agents", "/channels", @@ -182,7 +251,7 @@ func TestPageRoutes(t *testing.T) { } for _, path := range pages { t.Run(path, func(t *testing.T) { - rr := doRequest(t, srv, "GET", path, nil) + rr := doRequest(t, env, "GET", path, nil) if rr.Code != http.StatusOK { t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK) } @@ -202,22 +271,105 @@ func TestPageRoutes(t *testing.T) { } func TestUnknownPathReturns404(t *testing.T) { - srv := setupAdmin(t) + env := setupAdmin(t) - rr := doRequest(t, srv, "GET", "/nonexistent", nil) + rr := doRequest(t, env, "GET", "/nonexistent", nil) if rr.Code != http.StatusNotFound { t.Fatalf("status = %d, want %d", rr.Code, http.StatusNotFound) } } func TestCORSPreflight(t *testing.T) { - srv := setupAdmin(t) + env := setupAdmin(t) - rr := doRequest(t, srv, "OPTIONS", "/api/providers", nil) + rr := doRequest(t, env, "OPTIONS", "/api/providers", nil) if rr.Code != http.StatusNoContent { t.Fatalf("status = %d, want %d", rr.Code, http.StatusNoContent) } - if rr.Header().Get("Access-Control-Allow-Origin") != "*" { - t.Error("missing CORS header") + origin := rr.Header().Get("Access-Control-Allow-Origin") + if origin == "" { + t.Error("missing CORS origin header") + } + if rr.Header().Get("Access-Control-Allow-Credentials") != "true" { + t.Error("missing CORS credentials header") + } +} + +func TestLoginPageAccessible(t *testing.T) { + env := setupAdmin(t) + + rr := doUnauthRequest(t, env.srv, "GET", "/login", nil) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK) + } + ct := rr.Header().Get("Content-Type") + if ct != "text/html; charset=utf-8" { + t.Errorf("Content-Type = %q, want %q", ct, "text/html; charset=utf-8") + } +} + +func TestUnauthenticatedAPIReturns401(t *testing.T) { + env := setupAdmin(t) + + rr := doUnauthRequest(t, env.srv, "GET", "/api/agents", nil) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d (body: %s)", rr.Code, http.StatusUnauthorized, rr.Body.String()) + } +} + +func TestUnauthenticatedPageRedirectsToLogin(t *testing.T) { + env := setupAdmin(t) + + rr := doUnauthRequest(t, env.srv, "GET", "/agents", nil) + if rr.Code != http.StatusFound { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusFound) + } + loc := rr.Header().Get("Location") + if loc != "/login" { + t.Errorf("Location = %q, want %q", loc, "/login") + } +} + +func TestNonAdminCannotAccessAdminRoutes(t *testing.T) { + env := setupAdmin(t) + + // Create a non-admin user. + hash, _ := auth.HashPassword("userpassword") + user, err := env.authStore.CreateUser(context.Background(), "regularuser", hash) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + _ = env.authStore.AssignRole(context.Background(), user.ID, auth.RoleUser) + + sessionID := auth.NewSessionID() + _, err = env.authStore.CreateSession(context.Background(), auth.Session{ + ID: sessionID, + UserID: user.ID, + ExpiresAt: time.Now().Add(auth.SessionDuration), + }) + if err != nil { + t.Fatalf("CreateSession: %v", err) + } + + // Admin-only API should return 403. + rr := doRequestWithSession(t, env.srv, sessionID, "GET", "/api/providers", nil) + if rr.Code != http.StatusForbidden { + t.Fatalf("status = %d, want %d (body: %s)", rr.Code, http.StatusForbidden, rr.Body.String()) + } + + // Admin-only page should redirect to /agents. + rr = doRequestWithSession(t, env.srv, sessionID, "GET", "/providers", nil) + if rr.Code != http.StatusFound { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusFound) + } + loc := rr.Header().Get("Location") + if loc != "/agents" { + t.Errorf("Location = %q, want %q", loc, "/agents") + } + + // Non-admin page should be accessible. + rr = doRequestWithSession(t, env.srv, sessionID, "GET", "/agents", nil) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK) } } From 09d6c7868b9684a4de68a258cb6cad7f1d06ecfb Mon Sep 17 00:00:00 2001 From: Vaayne Date: Fri, 20 Mar 2026 19:23:39 +0800 Subject: [PATCH 16/53] =?UTF-8?q?=F0=9F=93=9D=20docs:=20update=20tasks=20a?= =?UTF-8?q?nd=20handoff=20for=20Phase=203=20completion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/sessions/2026-03-20-rbac/handoff.md | 125 ++++++++++++++++++++ .agents/sessions/2026-03-20-rbac/tasks.md | 36 +++--- 2 files changed, 143 insertions(+), 18 deletions(-) diff --git a/.agents/sessions/2026-03-20-rbac/handoff.md b/.agents/sessions/2026-03-20-rbac/handoff.md index 011ae6b8..67d062e0 100644 --- a/.agents/sessions/2026-03-20-rbac/handoff.md +++ b/.agents/sessions/2026-03-20-rbac/handoff.md @@ -53,3 +53,128 @@ - **`ctx_agent_memory.user_id` FK**: Currently references `settings_users(id)`. This needs attention when handling data migration (not in scope for Phase 1). The FK target will need to change to `auth_users(id)` with a data migration step. - **`settings_agents.scope`**: Column added to schema and migration. The existing sqlc-generated code now includes `Scope` in `SettingsAgent` model, but existing queries (CreateAgent, UpdateAgent) do not set it — it defaults to `'system'`. The `config.Agent` struct and `agentFromDB` helper do NOT yet map the scope field (deferred to Phase 5 task 5.1). - **Pre-existing test failures**: Integration tests in `internal/agent/` and `internal/agent/runner/` fail due to missing API keys — these are not related to this phase's changes. + +## Phase 2: Policy Engine + +**Status:** Complete +**Date:** 2026-03-20 +**Commits:** 4 commits on `main` + +### What was done + +1. **Policy Engine** (`internal/auth/engine.go`): + - `PolicyEngine` struct holding sorted policies (by priority desc, then ID asc) + - `NewEngine(ctx, store)` — loads all enabled policies from AuthStore at startup + - `NewEngineFromPolicies(policies)` — constructor from pre-loaded policies (for testing) + - `Can(ctx, req) bool` — deny-overrides evaluation + - `Must(ctx, req) error` — returns `ErrAccessDenied` on denial + - Policy matching: `matchSubjects` (role intersection, wildcard `*`), `matchActions`, `matchResources` + +2. **Condition Evaluator** (`internal/auth/condition.go`): + - Parses JSON conditions: `{"resource.owner_id": {"eq": "subject.id"}}` + - Operators: `eq`, `neq`, `in`, `not_in`, `contains` + - Attribute resolution: `subject.id`, `subject.roles`, `subject.agent_ids`, `resource.type`, `resource.id`, `resource.owner_id`, plus custom attrs via `Attrs` maps + - Values can be attribute references (prefixed with `subject.` or `resource.`) or literals + - All conditions AND'd together + +3. **Seed** (`internal/auth/seed.go`): + - `SeedRolesAndPolicies(ctx, store)` — idempotent seeding + - 2 system roles: `admin`, `user` (both `is_system=true`) + - 8 built-in policies matching the plan's table (admin full access, user system agents, user assigned agents, user own sessions/data/skills/profile, user view agents list) + - Unique constraint violations are silently skipped for idempotency + +4. **Bootstrap Integration** (`cmd/anna/commands.go`): + - Added `auth.SeedRolesAndPolicies` call in `setup()` after `store.SeedDefaults` + - Creates `authdb.Store` from the shared DB connection + +5. **Tests**: + - `internal/auth/condition_test.go` — 15 tests: all operators, attr refs, AND logic, invalid JSON, edge cases + - `internal/auth/engine_test.go` — 16 tests: deny-overrides, default deny, allow matching, Must, priority ordering, multiple roles, conflicting policies, built-in policy scenarios + - `internal/auth/seed_test.go` — 3 tests: seed correctness, idempotency (run twice), engine from seeded DB + - All tests pass with `-race` + +### Notes for next phases + +- **PolicyEngine is read-only**: Policies loaded once at startup. No reload mechanism. If custom policy UI is added later, add a version counter + reload method. +- **`contains` operator**: Left side resolves to a JSON array string, right side is a scalar. Used for checking if a collection attribute contains a value. +- **`in` operator**: Left side is a scalar, right side resolves to a collection (attribute ref to JSON array or literal array). +- **Built-in policy for assigned agents**: Uses `{"resource.id":{"in":"subject.agent_ids"}}` — the caller must populate `Subject.AgentIDs` from `ListUserAgentIDs` before calling `Can`. + +## Phase 3: Admin UI Authentication + +**Status:** Complete +**Date:** 2026-03-20 +**Commits:** 4 commits on `main` + +### What was done + +1. **Session management** (`internal/auth/session.go`): + - `NewSessionID()` — 32 bytes from `crypto/rand`, hex-encoded (64 chars) + - `SetSessionCookie()` — HttpOnly, SameSite=Lax, Secure when not localhost, Path=/, 7-day MaxAge + - `ClearSessionCookie()`, `GetSessionCookie()` — cookie helpers + - Cookie name: `anna_session` + +2. **Rate limiting** (`internal/auth/ratelimit.go`): + - In-memory rate limiter using `sync.Map` + - Per-IP: max 10 attempts per minute with sliding window + - Per-username: 30-second cooldown after 5 consecutive failures + - `CheckIP`, `CheckUsername`, `RecordLoginFailure`, `RecordLoginSuccess` + +3. **Login page** (`internal/admin/ui/pages/login.templ`, `internal/admin/ui/static/js/pages/login.js`): + - Standalone page with `LoginLayout` (no navbar) + - Login form + register toggle with Alpine.js component + - Client-side password validation (match, min 8 chars) + - POST to `/api/auth/login` or `/api/auth/register`, redirect to `/` on success + +4. **Auth API handlers** (`internal/admin/auth.go`): + - `POST /api/auth/register` — validate min 8 char password, hash (bcrypt cost=12), create user, first user gets admin role, set session cookie + - `POST /api/auth/login` — rate-limit by IP + username, verify password, create DB session, set cookie + - `POST /api/auth/logout` — delete session from DB, clear cookie + - `GET /api/auth/me` — return current user info (id, username, roles, is_admin) + +5. **Auth middleware** (`internal/admin/middleware.go`): + - `authMiddleware` — extracts session cookie, loads session from DB (deletes if expired), loads user + roles, injects `AuthInfo` into context, extends session on each request + - `adminOnlyMiddleware` — checks `IsAdmin`, returns 403 for API routes, redirects to `/agents` for pages + - `UserFromContext(ctx)` — extracts `AuthInfo` from context + - Exempt paths: `/login`, `/static/`, `/api/auth/login`, `/api/auth/register`, `/api/auth/logout` + +6. **CORS hardening** (`internal/admin/server.go`): + - Replaced `Access-Control-Allow-Origin: *` with configurable origin from settings key `admin.cors_origin` + - Default: `http://localhost:8080` + - Added `Access-Control-Allow-Credentials: true` + +7. **Route guards** (`internal/admin/server.go`): + - Admin-only pages: providers, channels, users, scheduler, settings + - Admin-only APIs: providers/*, channels/*, users/*, settings/*, scheduler/* + - Non-admin accessible: agents, sessions, models, tools + +8. **Navbar** (`internal/admin/ui/navbar.templ`): + - Role-based visibility: admin-only items hidden for regular users + - Shows username + logout button + - Logo links to `/providers` (admin) or `/agents` (user) + +9. **Root redirect** (`internal/admin/server.go`): + - Unauthenticated -> `/login` + - Authenticated admin -> `/providers` + - Authenticated user -> `/agents` + +10. **Updated callers** (`cmd/anna/gateway.go`, `cmd/anna/onboard.go`): + - `admin.New()` now accepts `auth.AuthStore` and `*auth.PolicyEngine` + - Both callers create `authdb.Store` and `PolicyEngine` before creating admin server + +11. **Tests**: + - `internal/auth/session_test.go` — session ID generation, cookie set/get/clear, missing/empty cookie + - `internal/auth/ratelimit_test.go` — IP limiting, username cooldown, success reset, below-threshold + - `internal/admin/auth_test.go` — register, login, logout, /me, password validation, duplicate username, wrong password, first-user admin role, expired session + - `internal/admin/server_test.go` — updated for auth-aware server: session cookies, admin/non-admin access control, unauthenticated redirects, CORS credentials header + - All tests pass with `-race` + +### Notes for next phases + +- **`admin.New()` signature changed**: Now requires `auth.AuthStore` and `*auth.PolicyEngine` as parameters. All callers updated. +- **Layout signature changed**: `ui.Layout()` now takes `username string, isAdmin bool` parameters for navbar rendering. +- **Navbar signature changed**: `ui.Navbar()` now takes `activePage, username string, isAdmin bool`. +- **Auth middleware exempt paths**: Only `/login`, `/static/`, and three specific `/api/auth/` endpoints are exempt. The `/api/auth/me` endpoint goes through the middleware. +- **Session expiry extension**: Each authenticated request extends the session by 7 days (rolling expiry). +- **Lazy session cleanup**: Expired sessions are deleted on each middleware invocation via `DeleteExpiredSessions`. +- **CORS origin**: Reads from settings table key `admin.cors_origin`. Falls back to `http://localhost:8080`. Can be configured via the settings API. diff --git a/.agents/sessions/2026-03-20-rbac/tasks.md b/.agents/sessions/2026-03-20-rbac/tasks.md index 0b9eee78..be249dc6 100644 --- a/.agents/sessions/2026-03-20-rbac/tasks.md +++ b/.agents/sessions/2026-03-20-rbac/tasks.md @@ -15,27 +15,27 @@ ## Phase 2: Policy Engine -- [ ] 2.1 — Create `internal/auth/engine.go` (PolicyEngine, Can, Must) -- [ ] 2.2 — Implement condition evaluator (JSON conditions, operators: eq, neq, in, not_in, contains) -- [ ] 2.3 — Implement deny-overrides algorithm -- [ ] 2.4 — Create `internal/auth/seed.go` (8 built-in policies + 2 roles) -- [ ] 2.5 — Integrate seed into bootstrap -- [ ] 2.6 — Write tests (policy matching, conditions, deny-overrides, edge cases) +- [x] 2.1 — Create `internal/auth/engine.go` (PolicyEngine, Can, Must) +- [x] 2.2 — Implement condition evaluator (JSON conditions, operators: eq, neq, in, not_in, contains) +- [x] 2.3 — Implement deny-overrides algorithm +- [x] 2.4 — Create `internal/auth/seed.go` (8 built-in policies + 2 roles) +- [x] 2.5 — Integrate seed into bootstrap +- [x] 2.6 — Write tests (policy matching, conditions, deny-overrides, edge cases) ## Phase 3: Admin UI Authentication -- [ ] 3.1 — Create `internal/auth/session.go` (crypto/rand IDs, cookies, lazy cleanup) -- [ ] 3.2 — Create `internal/auth/ratelimit.go` (per-IP + per-username throttling) -- [ ] 3.3 — Create login/register templ page (`internal/admin/ui/pages/login.templ`) -- [ ] 3.4 — Create login page JS (`internal/admin/ui/static/js/pages/login.js`) -- [ ] 3.5 — Add auth API handlers (`internal/admin/auth.go`) -- [ ] 3.6 — Add auth middleware (`internal/admin/middleware.go`) -- [ ] 3.7 — Harden CORS in `server.go` -- [ ] 3.8 — Apply auth middleware to routes, exempt login/static/auth -- [ ] 3.9 — Add admin-only route guard middleware -- [ ] 3.10 — Modify navbar for role-based visibility -- [ ] 3.11 — Modify root redirect (unauthenticated → login) -- [ ] 3.12 — Write tests +- [x] 3.1 — Create `internal/auth/session.go` (crypto/rand IDs, cookies, lazy cleanup) +- [x] 3.2 — Create `internal/auth/ratelimit.go` (per-IP + per-username throttling) +- [x] 3.3 — Create login/register templ page (`internal/admin/ui/pages/login.templ`) +- [x] 3.4 — Create login page JS (`internal/admin/ui/static/js/pages/login.js`) +- [x] 3.5 — Add auth API handlers (`internal/admin/auth.go`) +- [x] 3.6 — Add auth middleware (`internal/admin/middleware.go`) +- [x] 3.7 — Harden CORS in `server.go` +- [x] 3.8 — Apply auth middleware to routes, exempt login/static/auth +- [x] 3.9 — Add admin-only route guard middleware +- [x] 3.10 — Modify navbar for role-based visibility +- [x] 3.11 — Modify root redirect (unauthenticated → login) +- [x] 3.12 — Write tests ## Phase 4: User Profile + Channel Linking From 34bb0e1adc1661bfc8ccecd697ef3c595f4b19b3 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Fri, 20 Mar 2026 19:34:38 +0800 Subject: [PATCH 17/53] =?UTF-8?q?=F0=9F=90=9B=20fix:=20address=20Phase=203?= =?UTF-8?q?=20review=20issues?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Separate CheckIP from RecordIPAttempt (no counter on check) - Add rate limiting to register endpoint - Add username (max 64) and password (max 72) length limits - Fix first-user race: check count before creating user - Make agent CRUD (POST/PUT/DELETE) admin-only - Cache CORS origin at startup instead of per-request DB query - Set Secure=true on ClearSessionCookie for HTTPS compat --- internal/admin/auth.go | 38 +++++++++++++++++++++++++-------- internal/admin/server.go | 32 ++++++++++++--------------- internal/auth/ratelimit.go | 28 +++++++++++++++++++++--- internal/auth/ratelimit_test.go | 24 ++++++++++++++++----- internal/auth/session.go | 3 +++ 5 files changed, 90 insertions(+), 35 deletions(-) diff --git a/internal/admin/auth.go b/internal/admin/auth.go index d81c610e..308112dc 100644 --- a/internal/admin/auth.go +++ b/internal/admin/auth.go @@ -10,6 +10,13 @@ import ( // registerHandler handles POST /api/auth/register. func (s *Server) registerHandler(w http.ResponseWriter, r *http.Request) { + // Rate limit registration by IP. + ip := clientIP(r) + if err := s.rateLimiter.CheckIP(ip); err != nil { + writeError(w, http.StatusTooManyRequests, err.Error()) + return + } + var body struct { Username string `json:"username"` Password string `json:"password"` @@ -24,13 +31,30 @@ func (s *Server) registerHandler(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusBadRequest, "username is required") return } + if len(body.Username) > 64 { + writeError(w, http.StatusBadRequest, "username must be at most 64 characters") + return + } if len(body.Password) < 8 { writeError(w, http.StatusBadRequest, "password must be at least 8 characters") return } + if len(body.Password) > 72 { + writeError(w, http.StatusBadRequest, "password must be at most 72 characters") + return + } ctx := r.Context() + // Check if this will be the first user BEFORE creating, to avoid race. + count, err := s.authStore.CountUsers(ctx) + if err != nil { + s.log.Error("count users", "error", err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + isFirstUser := count == 0 + // Hash password. hash, err := auth.HashPassword(body.Password) if err != nil { @@ -43,6 +67,7 @@ func (s *Server) registerHandler(w http.ResponseWriter, r *http.Request) { user, err := s.authStore.CreateUser(ctx, body.Username, hash) if err != nil { if strings.Contains(err.Error(), "UNIQUE constraint") { + s.rateLimiter.RecordIPAttempt(ip) writeError(w, http.StatusConflict, "username already taken") return } @@ -51,15 +76,8 @@ func (s *Server) registerHandler(w http.ResponseWriter, r *http.Request) { return } - // If first user, assign admin role; otherwise assign user role. - count, err := s.authStore.CountUsers(ctx) - if err != nil { - s.log.Error("count users", "error", err) - writeError(w, http.StatusInternalServerError, "internal error") - return - } - - if count == 1 { + // Assign roles. + if isFirstUser { _ = s.authStore.AssignRole(ctx, user.ID, auth.RoleAdmin) } _ = s.authStore.AssignRole(ctx, user.ID, auth.RoleUser) @@ -118,12 +136,14 @@ func (s *Server) loginHandler(w http.ResponseWriter, r *http.Request) { user, err := s.authStore.GetUserByUsername(ctx, body.Username) if err != nil { + s.rateLimiter.RecordIPAttempt(ip) s.rateLimiter.RecordLoginFailure(body.Username) writeError(w, http.StatusUnauthorized, "invalid username or password") return } if err := auth.CheckPassword(user.PasswordHash, body.Password); err != nil { + s.rateLimiter.RecordIPAttempt(ip) s.rateLimiter.RecordLoginFailure(body.Username) writeError(w, http.StatusUnauthorized, "invalid username or password") return diff --git a/internal/admin/server.go b/internal/admin/server.go index e0e4d3d8..c3fdee3d 100644 --- a/internal/admin/server.go +++ b/internal/admin/server.go @@ -24,10 +24,17 @@ type Server struct { q *sqlc.Queries mux *http.ServeMux log *slog.Logger + corsOriginV string // cached CORS origin } // New creates an admin server with all API routes mounted. func New(store config.Store, authStore auth.AuthStore, engine *auth.PolicyEngine, mem memory.Engine, db *sql.DB) *Server { + // Read CORS origin once at startup. + corsOrigin := "http://localhost:8080" + if val, err := store.GetSetting(context.Background(), "admin.cors_origin"); err == nil && val != "" { + corsOrigin = val + } + s := &Server{ store: store, authStore: authStore, @@ -38,6 +45,7 @@ func New(store config.Store, authStore auth.AuthStore, engine *auth.PolicyEngine q: sqlc.New(db), mux: http.NewServeMux(), log: slog.With("component", "admin"), + corsOriginV: corsOrigin, } // Serve static assets (JS modules). @@ -78,12 +86,12 @@ func New(store config.Store, authStore auth.AuthStore, engine *auth.PolicyEngine s.mux.Handle("DELETE /api/providers/{id}", adminAPI(s.deleteProvider)) s.mux.Handle("POST /api/providers/{id}/models", adminAPI(s.fetchProviderModels)) - // Agent APIs. + // Agent APIs (read for all authenticated users, write for admin only). s.mux.HandleFunc("GET /api/agents", s.listAgents) - s.mux.HandleFunc("POST /api/agents", s.createAgent) + s.mux.Handle("POST /api/agents", adminAPI(s.createAgent)) s.mux.HandleFunc("GET /api/agents/{id}", s.getAgent) - s.mux.HandleFunc("PUT /api/agents/{id}", s.updateAgent) - s.mux.HandleFunc("DELETE /api/agents/{id}", s.deleteAgent) + s.mux.Handle("PUT /api/agents/{id}", adminAPI(s.updateAgent)) + s.mux.Handle("DELETE /api/agents/{id}", adminAPI(s.deleteAgent)) // Channel APIs (admin-only). s.mux.Handle("GET /api/channels", adminAPI(s.listChannels)) @@ -142,12 +150,10 @@ func (s *Server) Handler() http.Handler { return s.corsMiddleware(s.authMiddleware(s.jsonMiddleware(s.mux))) } -// corsMiddleware handles CORS headers. Origin is configurable via the -// settings table key "admin.cors_origin"; defaults to http://localhost:8080. +// corsMiddleware handles CORS headers. Origin is read from settings at startup. func (s *Server) corsMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - origin := s.corsOrigin() - w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Access-Control-Allow-Origin", s.corsOriginV) w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type") w.Header().Set("Access-Control-Allow-Credentials", "true") @@ -161,16 +167,6 @@ func (s *Server) corsMiddleware(next http.Handler) http.Handler { }) } -// corsOrigin reads the CORS origin from the settings table. Falls back to -// http://localhost:8080 if not configured. -func (s *Server) corsOrigin() string { - val, err := s.store.GetSetting(context.Background(), "admin.cors_origin") - if err == nil && val != "" { - return val - } - return "http://localhost:8080" -} - // jsonMiddleware sets JSON content-type for /api/ routes. func (s *Server) jsonMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/auth/ratelimit.go b/internal/auth/ratelimit.go index 3e5551e6..a83a8df7 100644 --- a/internal/auth/ratelimit.go +++ b/internal/auth/ratelimit.go @@ -46,12 +46,16 @@ func NewRateLimiter() *RateLimiter { } // CheckIP verifies the IP has not exceeded the request limit. +// Does not increment the counter — call RecordIPAttempt after a failed attempt. func (rl *RateLimiter) CheckIP(ip string) error { now := time.Now() - val, _ := rl.ips.LoadOrStore(ip, &ipRecord{windowAt: now}) - rec := val.(*ipRecord) + val, ok := rl.ips.Load(ip) + if !ok { + return nil + } + rec := val.(*ipRecord) rec.mu.Lock() defer rec.mu.Unlock() @@ -59,16 +63,34 @@ func (rl *RateLimiter) CheckIP(ip string) error { if now.Sub(rec.windowAt) > ipWindowDuration { rec.attempts = 0 rec.windowAt = now + return nil } if rec.attempts >= ipMaxAttempts { return ErrRateLimitIP } - rec.attempts++ return nil } +// RecordIPAttempt records a failed attempt for rate limiting by IP. +func (rl *RateLimiter) RecordIPAttempt(ip string) { + now := time.Now() + + val, _ := rl.ips.LoadOrStore(ip, &ipRecord{windowAt: now}) + rec := val.(*ipRecord) + + rec.mu.Lock() + defer rec.mu.Unlock() + + if now.Sub(rec.windowAt) > ipWindowDuration { + rec.attempts = 0 + rec.windowAt = now + } + + rec.attempts++ +} + // CheckUsername verifies the username is not in a cooldown period. func (rl *RateLimiter) CheckUsername(username string) error { val, ok := rl.usernames.Load(username) diff --git a/internal/auth/ratelimit_test.go b/internal/auth/ratelimit_test.go index 2d70f07e..8a8c6efb 100644 --- a/internal/auth/ratelimit_test.go +++ b/internal/auth/ratelimit_test.go @@ -9,14 +9,17 @@ import ( func TestRateLimiterIPBasic(t *testing.T) { rl := auth.NewRateLimiter() - // Should allow 10 attempts. + // CheckIP alone should never block (no attempts recorded yet). + if err := rl.CheckIP("1.2.3.4"); err != nil { + t.Fatalf("fresh IP should not be limited: %v", err) + } + + // Record 10 failed attempts. for i := 0; i < 10; i++ { - if err := rl.CheckIP("1.2.3.4"); err != nil { - t.Fatalf("attempt %d: unexpected error: %v", i+1, err) - } + rl.RecordIPAttempt("1.2.3.4") } - // 11th should fail. + // 11th check should fail. if err := rl.CheckIP("1.2.3.4"); err != auth.ErrRateLimitIP { t.Errorf("expected ErrRateLimitIP, got %v", err) } @@ -27,6 +30,17 @@ func TestRateLimiterIPBasic(t *testing.T) { } } +func TestRateLimiterIPSuccessDoesNotCount(t *testing.T) { + rl := auth.NewRateLimiter() + + // CheckIP many times without RecordIPAttempt should never block. + for i := 0; i < 20; i++ { + if err := rl.CheckIP("1.2.3.4"); err != nil { + t.Fatalf("successful requests should not be counted: %v", err) + } + } +} + func TestRateLimiterUsernameFailures(t *testing.T) { rl := auth.NewRateLimiter() diff --git a/internal/auth/session.go b/internal/auth/session.go index 8964aede..1b0ca77b 100644 --- a/internal/auth/session.go +++ b/internal/auth/session.go @@ -47,6 +47,8 @@ func SetSessionCookie(w http.ResponseWriter, sessionID string, secure bool) { } // ClearSessionCookie removes the session cookie from the response. +// Setting Secure=true is harmless on HTTP and ensures the cookie is properly +// cleared on HTTPS deployments. func ClearSessionCookie(w http.ResponseWriter) { http.SetCookie(w, &http.Cookie{ Name: SessionCookieName, @@ -54,6 +56,7 @@ func ClearSessionCookie(w http.ResponseWriter) { Path: "/", MaxAge: -1, HttpOnly: true, + Secure: true, SameSite: http.SameSiteLaxMode, }) } From 5fab37ddbd501da6ac657c60f608d80b0bbe59db Mon Sep 17 00:00:00 2001 From: Vaayne Date: Fri, 20 Mar 2026 19:40:17 +0800 Subject: [PATCH 18/53] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20profile=20page?= =?UTF-8?q?=20with=20password=20change,=20identity=20linking,=20and=20link?= =?UTF-8?q?=20code=20store?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create internal/auth/linkcode.go: in-memory LinkCodeStore with sync.Map, 5-min TTL - Create profile templ page with password change form, linked identities list, and link code generation - Create profile.js Alpine component with full CRUD for identities and password - Create profile API handlers: list/unlink identities, change password, generate link code - Add /profile page route, profile API routes, and LinkCodes accessor on Server - Make username in navbar a clickable link to /profile --- internal/admin/profile.go | 159 +++++++++++++++++++ internal/admin/render.go | 4 + internal/admin/server.go | 14 ++ internal/admin/ui/navbar.templ | 2 +- internal/admin/ui/navbar_templ.go | 6 +- internal/admin/ui/pages/profile.templ | 127 +++++++++++++++ internal/admin/ui/pages/profile_templ.go | 120 ++++++++++++++ internal/admin/ui/static/js/pages/profile.js | 96 +++++++++++ internal/auth/linkcode.go | 96 +++++++++++ 9 files changed, 620 insertions(+), 4 deletions(-) create mode 100644 internal/admin/profile.go create mode 100644 internal/admin/ui/pages/profile.templ create mode 100644 internal/admin/ui/pages/profile_templ.go create mode 100644 internal/admin/ui/static/js/pages/profile.js create mode 100644 internal/auth/linkcode.go diff --git a/internal/admin/profile.go b/internal/admin/profile.go new file mode 100644 index 00000000..c3a982c0 --- /dev/null +++ b/internal/admin/profile.go @@ -0,0 +1,159 @@ +package admin + +import ( + "net/http" + "strconv" + + "github.com/vaayne/anna/internal/auth" +) + +// listProfileIdentities handles GET /api/auth/profile/identities. +func (s *Server) listProfileIdentities(w http.ResponseWriter, r *http.Request) { + info := UserFromContext(r.Context()) + if info == nil { + writeError(w, http.StatusUnauthorized, "not authenticated") + return + } + + identities, err := s.authStore.ListIdentitiesByUser(r.Context(), info.UserID) + if err != nil { + s.log.Error("list identities", "user_id", info.UserID, "error", err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + + writeData(w, http.StatusOK, identities) +} + +// changePassword handles PUT /api/auth/profile/password. +func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) { + info := UserFromContext(r.Context()) + if info == nil { + writeError(w, http.StatusUnauthorized, "not authenticated") + return + } + + var body struct { + CurrentPassword string `json:"current_password"` + NewPassword string `json:"new_password"` + } + if err := decodeJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + + if body.CurrentPassword == "" { + writeError(w, http.StatusBadRequest, "current password is required") + return + } + if len(body.NewPassword) < 8 { + writeError(w, http.StatusBadRequest, "new password must be at least 8 characters") + return + } + if len(body.NewPassword) > 72 { + writeError(w, http.StatusBadRequest, "new password must be at most 72 characters") + return + } + + ctx := r.Context() + + // Verify current password. + user, err := s.authStore.GetUser(ctx, info.UserID) + if err != nil { + s.log.Error("get user for password change", "user_id", info.UserID, "error", err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + + if err := auth.CheckPassword(user.PasswordHash, body.CurrentPassword); err != nil { + writeError(w, http.StatusUnauthorized, "current password is incorrect") + return + } + + // Hash and save new password. + hash, err := auth.HashPassword(body.NewPassword) + if err != nil { + s.log.Error("hash new password", "error", err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + + user.PasswordHash = hash + if err := s.authStore.UpdateUser(ctx, user); err != nil { + s.log.Error("update password", "user_id", info.UserID, "error", err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + + writeData(w, http.StatusOK, map[string]string{"status": "password changed"}) +} + +// generateLinkCode handles POST /api/auth/profile/link-code. +func (s *Server) generateLinkCode(w http.ResponseWriter, r *http.Request) { + info := UserFromContext(r.Context()) + if info == nil { + writeError(w, http.StatusUnauthorized, "not authenticated") + return + } + + var body struct { + Platform string `json:"platform"` + } + if err := decodeJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + + switch body.Platform { + case "telegram", "qq", "feishu": + // valid + default: + writeError(w, http.StatusBadRequest, "platform must be telegram, qq, or feishu") + return + } + + code := s.linkCodes.Generate(info.UserID, body.Platform) + + writeData(w, http.StatusOK, map[string]string{ + "code": code, + "platform": body.Platform, + }) +} + +// unlinkIdentity handles DELETE /api/auth/profile/identities/{id}. +func (s *Server) unlinkIdentity(w http.ResponseWriter, r *http.Request) { + info := UserFromContext(r.Context()) + if info == nil { + writeError(w, http.StatusUnauthorized, "not authenticated") + return + } + + idStr := r.PathValue("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid identity ID") + return + } + + ctx := r.Context() + + // Verify the identity belongs to the current user. + identity, err := s.authStore.GetIdentity(ctx, id) + if err != nil { + writeError(w, http.StatusNotFound, "identity not found") + return + } + + if identity.UserID != info.UserID { + writeError(w, http.StatusForbidden, "identity does not belong to you") + return + } + + if err := s.authStore.DeleteIdentity(ctx, id); err != nil { + s.log.Error("delete identity", "id", id, "error", err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + + writeData(w, http.StatusOK, map[string]string{"status": "unlinked"}) +} diff --git a/internal/admin/render.go b/internal/admin/render.go index af85918f..63411d25 100644 --- a/internal/admin/render.go +++ b/internal/admin/render.go @@ -43,6 +43,10 @@ func (s *Server) pageSettings(w http.ResponseWriter, r *http.Request) { s.renderPage(w, r, "settings", "/static/js/pages/settings.js", pages.SettingsPage()) } +func (s *Server) pageProfile(w http.ResponseWriter, r *http.Request) { + s.renderPage(w, r, "profile", "/static/js/pages/profile.js", pages.ProfilePage()) +} + // renderPage sets the HTML content type and renders the layout with the // given page content. Auth info is extracted from context for the navbar. func (s *Server) renderPage(w http.ResponseWriter, r *http.Request, activePage, pageScript string, content templ.Component) { diff --git a/internal/admin/server.go b/internal/admin/server.go index c3fdee3d..e1c30e1e 100644 --- a/internal/admin/server.go +++ b/internal/admin/server.go @@ -19,6 +19,7 @@ type Server struct { authStore auth.AuthStore engine *auth.PolicyEngine rateLimiter *auth.RateLimiter + linkCodes *auth.LinkCodeStore mem memory.Engine db *sql.DB q *sqlc.Queries @@ -40,6 +41,7 @@ func New(store config.Store, authStore auth.AuthStore, engine *auth.PolicyEngine authStore: authStore, engine: engine, rateLimiter: auth.NewRateLimiter(), + linkCodes: auth.NewLinkCodeStore(), mem: mem, db: db, q: sqlc.New(db), @@ -60,6 +62,12 @@ func New(store config.Store, authStore auth.AuthStore, engine *auth.PolicyEngine s.mux.HandleFunc("POST /api/auth/logout", s.logoutHandler) s.mux.HandleFunc("GET /api/auth/me", s.meHandler) + // Profile API routes (authenticated users). + s.mux.HandleFunc("GET /api/auth/profile/identities", s.listProfileIdentities) + s.mux.HandleFunc("PUT /api/auth/profile/password", s.changePassword) + s.mux.HandleFunc("POST /api/auth/profile/link-code", s.generateLinkCode) + s.mux.HandleFunc("DELETE /api/auth/profile/identities/{id}", s.unlinkIdentity) + // Page routes — templ-rendered HTML pages. // Admin-only pages: providers, channels, users, scheduler, settings. s.mux.Handle("GET /providers", s.adminOnlyMiddleware(http.HandlerFunc(s.pageProviders))) @@ -69,6 +77,7 @@ func New(store config.Store, authStore auth.AuthStore, engine *auth.PolicyEngine s.mux.HandleFunc("GET /sessions", s.pageSessions) s.mux.Handle("GET /scheduler", s.adminOnlyMiddleware(http.HandlerFunc(s.pageScheduler))) s.mux.Handle("GET /settings", s.adminOnlyMiddleware(http.HandlerFunc(s.pageSettings))) + s.mux.HandleFunc("GET /profile", s.pageProfile) // Root redirect based on auth status. s.mux.HandleFunc("GET /{$}", s.redirectRoot) @@ -145,6 +154,11 @@ func (s *Server) redirectRoot(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/agents", http.StatusFound) } +// LinkCodes returns the link code store for use by channel handlers. +func (s *Server) LinkCodes() *auth.LinkCodeStore { + return s.linkCodes +} + // Handler returns the HTTP handler with CORS, JSON, and auth middleware applied. func (s *Server) Handler() http.Handler { return s.corsMiddleware(s.authMiddleware(s.jsonMiddleware(s.mux))) diff --git a/internal/admin/ui/navbar.templ b/internal/admin/ui/navbar.templ index ae56e8d3..a9b4ffbc 100644 --- a/internal/admin/ui/navbar.templ +++ b/internal/admin/ui/navbar.templ @@ -75,7 +75,7 @@ templ Navbar(activePage string, username string, isAdmin bool) {
if username != "" { - { username } + { username }
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/internal/admin/ui/pages/profile.templ b/internal/admin/ui/pages/profile.templ new file mode 100644 index 00000000..b9a2b62f --- /dev/null +++ b/internal/admin/ui/pages/profile.templ @@ -0,0 +1,127 @@ +package pages + +import "github.com/vaayne/anna/internal/admin/ui" + +templ ProfilePage() { +
+ @ui.PageHeader("Profile", "Manage your account and linked identities.") + +
+

Change Password

+
+
+
+ @ui.FormField("Current Password") { + + } + @ui.FormField("New Password") { + + } + @ui.FormField("Confirm New Password") { + + } +
+
+ +
+
+
+
+ +
+

Linked Identities

+
+
+ + +
+ +
+
+
+
+ +
+

Link New Identity

+
+
+

+ Generate a link code, then send it as a message to the Anna bot on the selected platform. + The code expires in 5 minutes. +

+
+ + + +
+ +
+
+
+
+} diff --git a/internal/admin/ui/pages/profile_templ.go b/internal/admin/ui/pages/profile_templ.go new file mode 100644 index 00000000..f1eeb5a8 --- /dev/null +++ b/internal/admin/ui/pages/profile_templ.go @@ -0,0 +1,120 @@ +// Code generated by templ - DO NOT EDIT. + +// templ: version: v0.3.1001 +package pages + +//lint:file-ignore SA4006 This context is only used if a nested component is present. + +import "github.com/a-h/templ" +import templruntime "github.com/a-h/templ/runtime" + +import "github.com/vaayne/anna/internal/admin/ui" + +func ProfilePage() templ.Component { + return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil { + return templ_7745c5c3_CtxErr + } + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Var1 := templ.GetChildren(ctx) + if templ_7745c5c3_Var1 == nil { + templ_7745c5c3_Var1 = templ.NopComponent + } + ctx = templ.ClearChildren(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = ui.PageHeader("Profile", "Manage your account and linked identities.").Render(ctx, templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "

Change Password

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Var2 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = ui.FormField("Current Password").Render(templ.WithChildren(ctx, templ_7745c5c3_Var2), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Var3 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = ui.FormField("New Password").Render(templ.WithChildren(ctx, templ_7745c5c3_Var3), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Var4 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = ui.FormField("Confirm New Password").Render(templ.WithChildren(ctx, templ_7745c5c3_Var4), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "

Linked Identities

Link New Identity

Generate a link code, then send it as a message to the Anna bot on the selected platform. The code expires in 5 minutes.

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/internal/admin/ui/static/js/pages/profile.js b/internal/admin/ui/static/js/pages/profile.js new file mode 100644 index 00000000..04f4a463 --- /dev/null +++ b/internal/admin/ui/static/js/pages/profile.js @@ -0,0 +1,96 @@ +import { api } from '/static/js/api.js' + +/** + * Registers the profilePage Alpine.data component. + * + * @param {import('alpinejs').Alpine} Alpine + */ +export function register(Alpine) { + Alpine.data('profilePage', () => ({ + // Password change + currentPassword: '', + newPassword: '', + confirmPassword: '', + changingPassword: false, + + // Identities + identities: [], + loadingIdentities: false, + + // Link code + linkCode: '', + linkPlatform: '', + generating: false, + + async init() { + await this.loadIdentities() + }, + + async loadIdentities() { + this.loadingIdentities = true + try { + this.identities = await api('GET', '/api/auth/profile/identities') + } catch (e) { + this.$store.toast.show(e.message, 'error') + } finally { + this.loadingIdentities = false + } + }, + + async changePassword() { + if (!this.currentPassword || !this.newPassword) { + this.$store.toast.show('Please fill in all password fields', 'error') + return + } + if (this.newPassword.length < 8) { + this.$store.toast.show('New password must be at least 8 characters', 'error') + return + } + if (this.newPassword !== this.confirmPassword) { + this.$store.toast.show('New passwords do not match', 'error') + return + } + + this.changingPassword = true + try { + await api('PUT', '/api/auth/profile/password', { + current_password: this.currentPassword, + new_password: this.newPassword, + }) + this.$store.toast.show('Password changed successfully') + this.currentPassword = '' + this.newPassword = '' + this.confirmPassword = '' + } catch (e) { + this.$store.toast.show(e.message, 'error') + } finally { + this.changingPassword = false + } + }, + + async generateCode(platform) { + this.generating = true + this.linkPlatform = platform + this.linkCode = '' + try { + const result = await api('POST', '/api/auth/profile/link-code', { platform }) + this.linkCode = result.code + } catch (e) { + this.$store.toast.show(e.message, 'error') + } finally { + this.generating = false + } + }, + + async unlinkIdentity(id) { + if (!confirm('Unlink this identity?')) return + try { + await api('DELETE', '/api/auth/profile/identities/' + id) + this.$store.toast.show('Identity unlinked') + await this.loadIdentities() + } catch (e) { + this.$store.toast.show(e.message, 'error') + } + }, + })) +} diff --git a/internal/auth/linkcode.go b/internal/auth/linkcode.go new file mode 100644 index 00000000..8b50815e --- /dev/null +++ b/internal/auth/linkcode.go @@ -0,0 +1,96 @@ +package auth + +import ( + "crypto/rand" + "encoding/hex" + "strings" + "sync" + "time" +) + +const ( + // linkCodeLength is the number of characters in a link code. + linkCodeLength = 6 + + // linkCodeTTL is how long a link code remains valid. + linkCodeTTL = 5 * time.Minute +) + +// linkCodeEntry holds a pending link code with its owner and expiry. +type linkCodeEntry struct { + UserID int64 + Platform string + ExpireAt time.Time +} + +// LinkCodeStore manages in-memory link codes for channel account linking. +// Codes are single-use and expire after 5 minutes. Not persisted to DB; +// restart clears all pending codes (acceptable for MVP). +type LinkCodeStore struct { + codes sync.Map // string -> linkCodeEntry +} + +// NewLinkCodeStore creates a new link code store. +func NewLinkCodeStore() *LinkCodeStore { + return &LinkCodeStore{} +} + +// Generate creates a new 6-character alphanumeric link code for the given +// user and platform. Returns the code string. +func (s *LinkCodeStore) Generate(userID int64, platform string) string { + code := randomAlphanumeric(linkCodeLength) + s.codes.Store(code, linkCodeEntry{ + UserID: userID, + Platform: platform, + ExpireAt: time.Now().Add(linkCodeTTL), + }) + return code +} + +// Consume looks up a link code and returns the associated user ID and +// platform if valid. The code is consumed (deleted) on success. +// Returns (0, "", false) if the code is invalid or expired. +func (s *LinkCodeStore) Consume(code string) (int64, string, bool) { + code = strings.ToUpper(strings.TrimSpace(code)) + val, ok := s.codes.LoadAndDelete(code) + if !ok { + return 0, "", false + } + entry := val.(linkCodeEntry) + if time.Now().After(entry.ExpireAt) { + return 0, "", false + } + return entry.UserID, entry.Platform, true +} + +// IsLinkCode returns true if the string looks like a valid link code format +// (6 alphanumeric characters). This is a quick check before attempting Consume. +func IsLinkCode(s string) bool { + s = strings.TrimSpace(s) + if len(s) != linkCodeLength { + return false + } + for _, c := range s { + if !isAlphanumeric(c) { + return false + } + } + return true +} + +// randomAlphanumeric generates an uppercase alphanumeric string of length n. +func randomAlphanumeric(n int) string { + // Generate more bytes than needed to account for hex encoding, + // then filter to alphanumeric and take first n. + b := make([]byte, n*2) + if _, err := rand.Read(b); err != nil { + panic("auth: crypto/rand failed: " + err.Error()) + } + hex := strings.ToUpper(hex.EncodeToString(b)) + // Hex output is 0-9A-F, all alphanumeric. Take first n chars. + return hex[:n] +} + +func isAlphanumeric(c rune) bool { + return (c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') +} From 41f3c4a9c50119714eaf8d01876593236fe6eba1 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Fri, 20 Mar 2026 19:43:10 +0800 Subject: [PATCH 19/53] =?UTF-8?q?=E2=9C=A8=20feat:=20intercept=20link=20co?= =?UTF-8?q?des=20in=20channel=20handlers=20for=20account=20linking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create internal/channel/linkcode.go: shared TryLinkCode function - Add WithAuth option to telegram, qq, feishu bots - Intercept 6-char alphanumeric codes in text handlers before command processing - Create auth_identity on valid code, reply with success/error message - Platform mismatch detection (code for telegram sent to qq, etc.) --- internal/channel/feishu/feishu.go | 12 +++++++ internal/channel/feishu/handler.go | 8 +++++ internal/channel/linkcode.go | 51 +++++++++++++++++++++++++++ internal/channel/qq/handler.go | 10 +++++- internal/channel/qq/qq.go | 12 +++++++ internal/channel/telegram/handler.go | 14 ++++++++ internal/channel/telegram/telegram.go | 12 +++++++ 7 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 internal/channel/linkcode.go diff --git a/internal/channel/feishu/feishu.go b/internal/channel/feishu/feishu.go index 86d6497d..6371e30f 100644 --- a/internal/channel/feishu/feishu.go +++ b/internal/channel/feishu/feishu.go @@ -18,6 +18,7 @@ import ( larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" larkws "github.com/larksuite/oapi-sdk-go/v3/ws" "github.com/vaayne/anna/internal/agent" + "github.com/vaayne/anna/internal/auth" "github.com/vaayne/anna/internal/channel" "github.com/vaayne/anna/internal/config" ) @@ -50,6 +51,8 @@ type Bot struct { wsClient *larkws.Client poolManager *agent.PoolManager store config.Store + authStore auth.AuthStore + linkCodes *auth.LinkCodeStore listFn ModelListFunc switchFn ModelSwitchFunc @@ -68,6 +71,15 @@ type Bot struct { // BotOption configures the Feishu Bot. type BotOption func(*Bot) +// WithAuth configures the bot with auth store and link code store for +// account linking support. +func WithAuth(authStore auth.AuthStore, linkCodes *auth.LinkCodeStore) BotOption { + return func(b *Bot) { + b.authStore = authStore + b.linkCodes = linkCodes + } +} + // New creates a Feishu bot. Call Start to begin receiving events. func New(cfg Config, pm *agent.PoolManager, store config.Store, listFn ModelListFunc, switchFn ModelSwitchFunc, opts ...BotOption) (*Bot, error) { if cfg.AppID == "" || cfg.AppSecret == "" { diff --git a/internal/channel/feishu/handler.go b/internal/channel/feishu/handler.go index 76a2c8b3..fbc1830d 100644 --- a/internal/channel/feishu/handler.go +++ b/internal/channel/feishu/handler.go @@ -73,6 +73,14 @@ func (b *Bot) onMessage(ctx context.Context, event *larkim.P2MessageReceiveV1) e text = stripMentions(text, mentions) } + // Try link code before anything else. + if b.authStore != nil && b.linkCodes != nil && text != "" { + if resp, ok := channel.TryLinkCode(b.ctx, b.authStore, b.linkCodes, text, "feishu", openID, ""); ok { + replyFn(resp) + return nil + } + } + if text != "" { if handled := b.handleCommand(rc, text, openID, replyFn); handled { return nil diff --git a/internal/channel/linkcode.go b/internal/channel/linkcode.go new file mode 100644 index 00000000..03fd845c --- /dev/null +++ b/internal/channel/linkcode.go @@ -0,0 +1,51 @@ +package channel + +import ( + "context" + "fmt" + "log/slog" + "strings" + + "github.com/vaayne/anna/internal/auth" +) + +// TryLinkCode checks if a message text is a valid link code. If so, it +// consumes the code, creates an auth_identity linking the sender's channel +// account to the system user, and returns a response message + true. +// Returns ("", false) if the text is not a link code. +func TryLinkCode(ctx context.Context, authStore auth.AuthStore, linkCodes *auth.LinkCodeStore, text, platform, senderID, senderName string) (string, bool) { + text = strings.TrimSpace(text) + if !auth.IsLinkCode(text) { + return "", false + } + + userID, codePlatform, ok := linkCodes.Consume(text) + if !ok { + return "Invalid or expired link code. Please generate a new one from the admin profile page.", true + } + + // Verify the code was generated for this platform. + if codePlatform != platform { + return fmt.Sprintf("This link code was generated for %s, not %s. Please generate a new code for the correct platform.", codePlatform, platform), true + } + + // Check if this identity is already linked. + if existing, err := authStore.GetIdentityByPlatform(ctx, platform, senderID); err == nil { + return fmt.Sprintf("This %s account is already linked to user ID %d. Please unlink it first from the admin profile page.", platform, existing.UserID), true + } + + // Create the identity link. + _, err := authStore.CreateIdentity(ctx, auth.Identity{ + UserID: userID, + Platform: platform, + ExternalID: senderID, + Name: senderName, + }) + if err != nil { + slog.Error("link code: create identity failed", "platform", platform, "sender", senderID, "user_id", userID, "error", err) + return "Failed to link account. Please try again.", true + } + + slog.Info("link code: account linked", "platform", platform, "sender", senderID, "user_id", userID) + return "Account linked successfully! Your channel account is now connected to your system user.", true +} diff --git a/internal/channel/qq/handler.go b/internal/channel/qq/handler.go index cc290de6..5587e09c 100644 --- a/internal/channel/qq/handler.go +++ b/internal/channel/qq/handler.go @@ -25,6 +25,15 @@ func (b *Bot) c2cMessageHandler() event.C2CMessageEventHandler { return nil } + // Try link code before anything else. + text := strings.TrimSpace(msg.Content) + if b.authStore != nil && b.linkCodes != nil && text != "" { + if resp, ok := channel.TryLinkCode(b.ctx, b.authStore, b.linkCodes, text, "qq", authorID, ""); ok { + b.replyC2C(b.ctx, authorID, msg.ID, resp) + return nil + } + } + content := b.buildMessageContent(msg) if content == nil { return nil @@ -39,7 +48,6 @@ func (b *Bot) c2cMessageHandler() event.C2CMessageEventHandler { return nil } - text := strings.TrimSpace(msg.Content) if text != "" { if handled := b.handleCommand(rc, text, authorID, replyFn); handled { return nil diff --git a/internal/channel/qq/qq.go b/internal/channel/qq/qq.go index d0c071c7..341248ec 100644 --- a/internal/channel/qq/qq.go +++ b/internal/channel/qq/qq.go @@ -14,6 +14,7 @@ import ( "github.com/tencent-connect/botgo/openapi" "github.com/tencent-connect/botgo/token" "github.com/vaayne/anna/internal/agent" + "github.com/vaayne/anna/internal/auth" "github.com/vaayne/anna/internal/channel" "github.com/vaayne/anna/internal/config" "golang.org/x/oauth2" @@ -40,6 +41,8 @@ type Bot struct { sessionManager botgo.SessionManager poolManager *agent.PoolManager store config.Store + authStore auth.AuthStore + linkCodes *auth.LinkCodeStore listFn channel.ModelListFunc switchFn channel.ModelSwitchFunc @@ -55,6 +58,15 @@ type Bot struct { // BotOption configures the QQ Bot. type BotOption func(*Bot) +// WithAuth configures the bot with auth store and link code store for +// account linking support. +func WithAuth(authStore auth.AuthStore, linkCodes *auth.LinkCodeStore) BotOption { + return func(b *Bot) { + b.authStore = authStore + b.linkCodes = linkCodes + } +} + // New creates a QQ bot. Call Start to begin receiving events. func New(cfg Config, pm *agent.PoolManager, store config.Store, listFn channel.ModelListFunc, switchFn channel.ModelSwitchFunc, opts ...BotOption) (*Bot, error) { if cfg.AppID == "" || cfg.AppSecret == "" { diff --git a/internal/channel/telegram/handler.go b/internal/channel/telegram/handler.go index 9303f873..6b17ffa8 100644 --- a/internal/channel/telegram/handler.go +++ b/internal/channel/telegram/handler.go @@ -164,6 +164,20 @@ func (b *Bot) handleText(c tele.Context) error { text = b.stripBotMention(text) } + // Try link code before anything else. + if b.authStore != nil && b.linkCodes != nil { + sender := c.Sender() + if sender != nil { + name := sender.FirstName + if name == "" { + name = sender.Username + } + if resp, ok := channel.TryLinkCode(b.ctx, b.authStore, b.linkCodes, text, "telegram", strconv.FormatInt(sender.ID, 10), name); ok { + return c.Send(resp) + } + } + } + // Try shared command handler first. rc, err := b.resolve(c) if err != nil { diff --git a/internal/channel/telegram/telegram.go b/internal/channel/telegram/telegram.go index d9355865..854313dc 100644 --- a/internal/channel/telegram/telegram.go +++ b/internal/channel/telegram/telegram.go @@ -12,6 +12,7 @@ import ( tgmd "github.com/Mad-Pixels/goldmark-tgmd" "github.com/vaayne/anna/internal/agent" + "github.com/vaayne/anna/internal/auth" "github.com/vaayne/anna/internal/channel" "github.com/vaayne/anna/internal/config" tele "gopkg.in/telebot.v4" @@ -39,6 +40,8 @@ type Bot struct { bot *tele.Bot poolManager *agent.PoolManager store config.Store + authStore auth.AuthStore + linkCodes *auth.LinkCodeStore agentCmd *channel.AgentCommander listFn channel.ModelListFunc switchFn channel.ModelSwitchFunc @@ -98,6 +101,15 @@ func New(cfg Config, pm *agent.PoolManager, store config.Store, listFn channel.M // BotOption configures the Telegram Bot. type BotOption func(*Bot) +// WithAuth configures the bot with auth store and link code store for +// account linking support. +func WithAuth(authStore auth.AuthStore, linkCodes *auth.LinkCodeStore) BotOption { + return func(b *Bot) { + b.authStore = authStore + b.linkCodes = linkCodes + } +} + // Start begins long polling. It blocks until ctx is cancelled. func (b *Bot) Start(ctx context.Context) error { b.ctx = ctx From 229aa6913324ea21cebbdb4cc756cc410b61e2b4 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Fri, 20 Mar 2026 19:45:49 +0800 Subject: [PATCH 20/53] =?UTF-8?q?=E2=9C=A8=20feat:=20auth-aware=20identity?= =?UTF-8?q?=20resolution=20with=20auto-migration=20fallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ResolvedIdentity type with AuthUserID and Roles fields - Add ResolveUserWithAuth: auth_identities lookup -> auto-migrate from settings_users - Add ResolveWithAuth for auth-aware full resolution path - Auto-migration: creates auth_user + auth_identity for unlinked settings_users - Channel bots use ResolveWithAuth when authStore is configured - Backward compatible: falls back to legacy Resolve when auth not configured --- internal/channel/feishu/feishu.go | 18 +++- internal/channel/identity.go | 117 ++++++++++++++++++++++++++ internal/channel/qq/qq.go | 18 +++- internal/channel/resolved.go | 23 +++++ internal/channel/telegram/telegram.go | 24 +++++- 5 files changed, 195 insertions(+), 5 deletions(-) diff --git a/internal/channel/feishu/feishu.go b/internal/channel/feishu/feishu.go index 6371e30f..fe38c27c 100644 --- a/internal/channel/feishu/feishu.go +++ b/internal/channel/feishu/feishu.go @@ -245,6 +245,22 @@ func (b *Bot) isAllowed(openID string) bool { // resolve performs full user/agent/pool/session-key resolution for the // given Feishu message context. Call once per incoming message or command. func (b *Bot) resolve(openID, chatID, chatType string) (*channel.ResolvedChat, error) { + group := chatType == "group" + + if b.authStore != nil { + return channel.ResolveWithAuth( + context.Background(), + b.poolManager, + b.store, + b.authStore, + "feishu", + openID, + "", + chatID, + group, + ) + } + return channel.Resolve( context.Background(), b.poolManager, @@ -253,7 +269,7 @@ func (b *Bot) resolve(openID, chatID, chatType string) (*channel.ResolvedChat, e openID, "", chatID, - chatType == "group", + group, ) } diff --git a/internal/channel/identity.go b/internal/channel/identity.go index aa63a4d2..2788f278 100644 --- a/internal/channel/identity.go +++ b/internal/channel/identity.go @@ -2,8 +2,12 @@ package channel import ( "context" + "crypto/rand" + "encoding/hex" "fmt" + "log/slog" + "github.com/vaayne/anna/internal/auth" "github.com/vaayne/anna/internal/config" ) @@ -14,7 +18,16 @@ type ChatContext struct { IsGroup bool } +// ResolvedIdentity extends config.User with auth system information. +// When AuthUserID > 0, the user has been resolved via auth_identities. +type ResolvedIdentity struct { + config.User + AuthUserID int64 // auth_users.id (0 if not resolved via auth) + Roles []string // role IDs from auth_user_roles +} + // ResolveUser upserts a user by external ID + platform, returning the user record. +// This is the legacy path that only uses settings_users. func ResolveUser(ctx context.Context, store config.Store, externalID, platform, name string) (config.User, error) { user, err := store.UpsertUser(ctx, externalID, platform, name) if err != nil { @@ -23,6 +36,101 @@ func ResolveUser(ctx context.Context, store config.Store, externalID, platform, return user, nil } +// ResolveUserWithAuth resolves a channel user with the auth system. +// Resolution order: +// 1. Look up auth_identities for (platform, externalID) +// 2. If found: resolve to auth_user, also upsert settings_users for compatibility +// 3. If not found: fallback to settings_users, auto-migrate to auth system +// +// Auto-migration: when a settings_users record exists but no auth_identity, +// creates an auth_user (username="{platform}_{externalID}", random password) +// with the "user" role, and links the identity. +func ResolveUserWithAuth(ctx context.Context, store config.Store, authStore auth.AuthStore, externalID, platform, name string) (ResolvedIdentity, error) { + log := slog.With("component", "identity", "platform", platform, "external_id", externalID) + + // Always upsert in settings_users for backward compat (sessions, memories, etc.) + user, err := store.UpsertUser(ctx, externalID, platform, name) + if err != nil { + return ResolvedIdentity{}, fmt.Errorf("resolve user: %w", err) + } + + // Try auth_identities first. + identity, err := authStore.GetIdentityByPlatform(ctx, platform, externalID) + if err == nil { + // Found linked identity — resolve the auth user. + authUser, err := authStore.GetUser(ctx, identity.UserID) + if err != nil { + log.Error("auth user not found for linked identity", "user_id", identity.UserID, "error", err) + return ResolvedIdentity{User: user}, nil + } + if !authUser.IsActive { + return ResolvedIdentity{}, fmt.Errorf("account is deactivated") + } + roles, _ := authStore.ListUserRoles(ctx, authUser.ID) + roleIDs := make([]string, len(roles)) + for i, r := range roles { + roleIDs[i] = r.ID + } + return ResolvedIdentity{ + User: user, + AuthUserID: authUser.ID, + Roles: roleIDs, + }, nil + } + + // Not found in auth_identities — auto-migrate from settings_users. + log.Info("auto-migrating channel user to auth system") + + username := fmt.Sprintf("%s_%s", platform, externalID) + password := randomPassword() + hash, err := auth.HashPassword(password) + if err != nil { + log.Error("hash password for auto-migration", "error", err) + return ResolvedIdentity{User: user}, nil + } + + authUser, err := authStore.CreateUser(ctx, username, hash) + if err != nil { + // May already exist (race condition or prior partial migration). + existing, getErr := authStore.GetUserByUsername(ctx, username) + if getErr != nil { + log.Error("create auto-migrated auth user", "error", err) + return ResolvedIdentity{User: user}, nil + } + authUser = existing + } else { + // Assign user role. + _ = authStore.AssignRole(ctx, authUser.ID, auth.RoleUser) + } + + // Create the identity link. + displayName := name + if displayName == "" { + displayName = externalID + } + _, err = authStore.CreateIdentity(ctx, auth.Identity{ + UserID: authUser.ID, + Platform: platform, + ExternalID: externalID, + Name: displayName, + }) + if err != nil { + log.Error("create auto-migrated identity", "error", err) + // Still return the user — identity creation failed but auth user exists. + } + + roles, _ := authStore.ListUserRoles(ctx, authUser.ID) + roleIDs := make([]string, len(roles)) + for i, r := range roles { + roleIDs[i] = r.ID + } + return ResolvedIdentity{ + User: user, + AuthUserID: authUser.ID, + Roles: roleIDs, + }, nil +} + // ResolveAgent determines which agent to route to. // DM: user's default_agent_id // Group: chat_agents(platform, chat_id) @@ -51,3 +159,12 @@ func ResolveAgent(ctx context.Context, store config.Store, user config.User, cha } return agents[0].ID, nil } + +// randomPassword generates a random 16-byte hex password for auto-migrated users. +func randomPassword() string { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + panic("channel: crypto/rand failed: " + err.Error()) + } + return hex.EncodeToString(b) +} diff --git a/internal/channel/qq/qq.go b/internal/channel/qq/qq.go index 341248ec..3094813d 100644 --- a/internal/channel/qq/qq.go +++ b/internal/channel/qq/qq.go @@ -208,6 +208,22 @@ func channelForGroup(groupID string) string { // resolve performs full user/agent/pool/session-key resolution for the // given QQ message context. Call once per incoming message or command. func (b *Bot) resolve(authorID, groupID string) (*channel.ResolvedChat, error) { + group := groupID != "" + + if b.authStore != nil { + return channel.ResolveWithAuth( + context.Background(), + b.poolManager, + b.store, + b.authStore, + "qq", + authorID, + "", + groupID, + group, + ) + } + return channel.Resolve( context.Background(), b.poolManager, @@ -216,6 +232,6 @@ func (b *Bot) resolve(authorID, groupID string) (*channel.ResolvedChat, error) { authorID, "", groupID, - groupID != "", + group, ) } diff --git a/internal/channel/resolved.go b/internal/channel/resolved.go index 02392624..8b03081f 100644 --- a/internal/channel/resolved.go +++ b/internal/channel/resolved.go @@ -6,6 +6,7 @@ import ( "github.com/vaayne/anna/internal/agent" "github.com/vaayne/anna/internal/agent/runner" + "github.com/vaayne/anna/internal/auth" "github.com/vaayne/anna/internal/config" ) @@ -19,6 +20,9 @@ type ResolvedChat struct { AgentID string SessionKey string ChatCtx ChatContext + // Auth fields (populated when auth is enabled). + AuthUserID int64 + Roles []string } // UserID is a convenience accessor for rc.User.ID. @@ -57,6 +61,22 @@ func Resolve(ctx context.Context, pm *agent.PoolManager, store config.Store, pla return nil, fmt.Errorf("resolve user: %w", err) } + return resolveWithUser(ctx, pm, store, user, 0, nil, platform, chatID, isGroup) +} + +// ResolveWithAuth performs auth-aware user -> agent -> pool -> session key resolution. +// Uses auth_identities for identity resolution with auto-migration fallback. +func ResolveWithAuth(ctx context.Context, pm *agent.PoolManager, store config.Store, authStore auth.AuthStore, platform, senderID, senderName, chatID string, isGroup bool) (*ResolvedChat, error) { + resolved, err := ResolveUserWithAuth(ctx, store, authStore, senderID, platform, senderName) + if err != nil { + return nil, fmt.Errorf("resolve user: %w", err) + } + + return resolveWithUser(ctx, pm, store, resolved.User, resolved.AuthUserID, resolved.Roles, platform, chatID, isGroup) +} + +// resolveWithUser performs agent -> pool -> session key resolution given a resolved user. +func resolveWithUser(ctx context.Context, pm *agent.PoolManager, store config.Store, user config.User, authUserID int64, roles []string, platform, chatID string, isGroup bool) (*ResolvedChat, error) { chatCtx := ChatContext{ Platform: platform, ChatID: chatID, @@ -77,6 +97,7 @@ func Resolve(ctx context.Context, pm *agent.PoolManager, store config.Store, pla if isGroup && chatID != "" { channelCtx = "group:" + chatID } + senderID := user.ExternalID sessionKey := agent.BuildSessionKey(agentID, platform, senderID, channelCtx) return &ResolvedChat{ @@ -85,5 +106,7 @@ func Resolve(ctx context.Context, pm *agent.PoolManager, store config.Store, pla AgentID: agentID, SessionKey: sessionKey, ChatCtx: chatCtx, + AuthUserID: authUserID, + Roles: roles, }, nil } diff --git a/internal/channel/telegram/telegram.go b/internal/channel/telegram/telegram.go index 854313dc..ad6bf777 100644 --- a/internal/channel/telegram/telegram.go +++ b/internal/channel/telegram/telegram.go @@ -273,14 +273,32 @@ func (b *Bot) resolve(c tele.Context) (*channel.ResolvedChat, error) { name = sender.Username } + senderID := strconv.FormatInt(sender.ID, 10) + chatID := strconv.FormatInt(c.Chat().ID, 10) + group := isGroup(c) + + if b.authStore != nil { + return channel.ResolveWithAuth( + context.Background(), + b.poolManager, + b.store, + b.authStore, + "telegram", + senderID, + name, + chatID, + group, + ) + } + return channel.Resolve( context.Background(), b.poolManager, b.store, "telegram", - strconv.FormatInt(sender.ID, 10), + senderID, name, - strconv.FormatInt(c.Chat().ID, 10), - isGroup(c), + chatID, + group, ) } From c81caa8a275dadf76d5ea85a1dea9a9f393b08f5 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Fri, 20 Mar 2026 19:54:17 +0800 Subject: [PATCH 21/53] =?UTF-8?q?=E2=9C=85=20test:=20add=20tests=20for=20l?= =?UTF-8?q?ink=20codes,=20profile=20handlers,=20and=20auth-aware=20identit?= =?UTF-8?q?y=20resolution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LinkCodeStore tests: generate, consume, single-use, case-insensitive, uniqueness, IsLinkCode - Profile API tests: list identities, change password, generate link code, unlink identity, ownership check - Identity resolution tests: auto-migration, linked identity lookup, idempotency - TryLinkCode tests: success, wrong platform, invalid code, non-code text - All tests pass with -race --- internal/admin/profile_test.go | 224 ++++++++++++++++++++++++++++++ internal/auth/linkcode_test.go | 135 ++++++++++++++++++ internal/channel/identity_test.go | 204 +++++++++++++++++++++++++++ 3 files changed, 563 insertions(+) create mode 100644 internal/admin/profile_test.go create mode 100644 internal/auth/linkcode_test.go diff --git a/internal/admin/profile_test.go b/internal/admin/profile_test.go new file mode 100644 index 00000000..e4d721b6 --- /dev/null +++ b/internal/admin/profile_test.go @@ -0,0 +1,224 @@ +package admin_test + +import ( + "context" + "encoding/json" + "net/http" + "strconv" + "testing" + + "github.com/vaayne/anna/internal/auth" +) + +func TestListProfileIdentitiesEmpty(t *testing.T) { + env := setupAdmin(t) + + rr := doRequest(t, env, "GET", "/api/auth/profile/identities", nil) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want %d (body: %s)", rr.Code, http.StatusOK, rr.Body.String()) + } + + resp := parseResponse(t, rr) + var identities []auth.Identity + if err := json.Unmarshal(resp.Data, &identities); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(identities) != 0 { + t.Errorf("expected 0 identities, got %d", len(identities)) + } +} + +func TestListProfileIdentitiesWithLink(t *testing.T) { + env := setupAdmin(t) + ctx := context.Background() + + // Create an identity for the admin user. + _, err := env.authStore.CreateIdentity(ctx, auth.Identity{ + UserID: env.adminUser.ID, + Platform: "telegram", + ExternalID: "12345", + Name: "TestAdmin", + }) + if err != nil { + t.Fatalf("CreateIdentity: %v", err) + } + + rr := doRequest(t, env, "GET", "/api/auth/profile/identities", nil) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK) + } + + resp := parseResponse(t, rr) + var identities []auth.Identity + if err := json.Unmarshal(resp.Data, &identities); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(identities) != 1 { + t.Fatalf("expected 1 identity, got %d", len(identities)) + } + if identities[0].Platform != "telegram" { + t.Errorf("platform = %q, want %q", identities[0].Platform, "telegram") + } +} + +func TestChangePasswordSuccess(t *testing.T) { + env := setupAdmin(t) + + body := map[string]string{ + "current_password": "testpassword", + "new_password": "newpassword123", + } + rr := doRequest(t, env, "PUT", "/api/auth/profile/password", body) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want %d (body: %s)", rr.Code, http.StatusOK, rr.Body.String()) + } + + // Verify the new password works. + user, err := env.authStore.GetUser(context.Background(), env.adminUser.ID) + if err != nil { + t.Fatalf("GetUser: %v", err) + } + if err := auth.CheckPassword(user.PasswordHash, "newpassword123"); err != nil { + t.Error("new password should work after change") + } +} + +func TestChangePasswordWrongCurrent(t *testing.T) { + env := setupAdmin(t) + + body := map[string]string{ + "current_password": "wrongpassword", + "new_password": "newpassword123", + } + rr := doRequest(t, env, "PUT", "/api/auth/profile/password", body) + if rr.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusUnauthorized) + } +} + +func TestChangePasswordTooShort(t *testing.T) { + env := setupAdmin(t) + + body := map[string]string{ + "current_password": "testpassword", + "new_password": "short", + } + rr := doRequest(t, env, "PUT", "/api/auth/profile/password", body) + if rr.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusBadRequest) + } +} + +func TestGenerateLinkCode(t *testing.T) { + env := setupAdmin(t) + + body := map[string]string{ + "platform": "telegram", + } + rr := doRequest(t, env, "POST", "/api/auth/profile/link-code", body) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want %d (body: %s)", rr.Code, http.StatusOK, rr.Body.String()) + } + + resp := parseResponse(t, rr) + var result struct { + Code string `json:"code"` + Platform string `json:"platform"` + } + if err := json.Unmarshal(resp.Data, &result); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(result.Code) != 6 { + t.Errorf("code length = %d, want 6", len(result.Code)) + } + if result.Platform != "telegram" { + t.Errorf("platform = %q, want %q", result.Platform, "telegram") + } +} + +func TestGenerateLinkCodeInvalidPlatform(t *testing.T) { + env := setupAdmin(t) + + body := map[string]string{ + "platform": "invalid", + } + rr := doRequest(t, env, "POST", "/api/auth/profile/link-code", body) + if rr.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusBadRequest) + } +} + +func TestUnlinkIdentity(t *testing.T) { + env := setupAdmin(t) + ctx := context.Background() + + // Create an identity. + identity, err := env.authStore.CreateIdentity(ctx, auth.Identity{ + UserID: env.adminUser.ID, + Platform: "telegram", + ExternalID: "54321", + Name: "TestAdmin", + }) + if err != nil { + t.Fatalf("CreateIdentity: %v", err) + } + + rr := doRequest(t, env, "DELETE", "/api/auth/profile/identities/"+itoa(identity.ID), nil) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want %d (body: %s)", rr.Code, http.StatusOK, rr.Body.String()) + } + + // Verify it's gone. + identities, err := env.authStore.ListIdentitiesByUser(ctx, env.adminUser.ID) + if err != nil { + t.Fatalf("ListIdentitiesByUser: %v", err) + } + if len(identities) != 0 { + t.Errorf("expected 0 identities after unlink, got %d", len(identities)) + } +} + +func TestUnlinkIdentityOtherUser(t *testing.T) { + env := setupAdmin(t) + ctx := context.Background() + + // Create another user. + hash, _ := auth.HashPassword("otherpassword") + otherUser, err := env.authStore.CreateUser(ctx, "otheruser", hash) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + + // Create an identity for the other user. + identity, err := env.authStore.CreateIdentity(ctx, auth.Identity{ + UserID: otherUser.ID, + Platform: "qq", + ExternalID: "99999", + Name: "Other", + }) + if err != nil { + t.Fatalf("CreateIdentity: %v", err) + } + + // Try to unlink it as the admin — should fail (not your identity). + rr := doRequest(t, env, "DELETE", "/api/auth/profile/identities/"+itoa(identity.ID), nil) + if rr.Code != http.StatusForbidden { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusForbidden) + } +} + +func TestProfilePageRoute(t *testing.T) { + env := setupAdmin(t) + + rr := doRequest(t, env, "GET", "/profile", nil) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK) + } + if ct := rr.Header().Get("Content-Type"); ct != "text/html; charset=utf-8" { + t.Errorf("Content-Type = %q, want %q", ct, "text/html; charset=utf-8") + } +} + +func itoa(i int64) string { + return strconv.FormatInt(i, 10) +} diff --git a/internal/auth/linkcode_test.go b/internal/auth/linkcode_test.go new file mode 100644 index 00000000..e47a91b4 --- /dev/null +++ b/internal/auth/linkcode_test.go @@ -0,0 +1,135 @@ +package auth_test + +import ( + "strings" + "testing" + + "github.com/vaayne/anna/internal/auth" +) + +func TestLinkCodeGenerate(t *testing.T) { + store := auth.NewLinkCodeStore() + + code := store.Generate(42, "telegram") + if len(code) != 6 { + t.Errorf("code length = %d, want 6", len(code)) + } + // Code should be uppercase hex (0-9A-F). + for _, c := range code { + if (c < '0' || c > '9') && (c < 'A' || c > 'F') { + t.Errorf("unexpected character in code: %c", c) + } + } +} + +func TestLinkCodeConsume(t *testing.T) { + store := auth.NewLinkCodeStore() + + code := store.Generate(42, "telegram") + + // Consume should succeed. + userID, platform, ok := store.Consume(code) + if !ok { + t.Fatal("expected Consume to succeed") + } + if userID != 42 { + t.Errorf("userID = %d, want 42", userID) + } + if platform != "telegram" { + t.Errorf("platform = %q, want %q", platform, "telegram") + } + + // Second consume should fail (single use). + _, _, ok = store.Consume(code) + if ok { + t.Error("expected second Consume to fail") + } +} + +func TestLinkCodeConsumeCaseInsensitive(t *testing.T) { + store := auth.NewLinkCodeStore() + + code := store.Generate(7, "qq") + + // Consume with lowercase should also work. + _, _, ok := store.Consume(strings.ToLower(code)) + if !ok { + t.Error("expected case-insensitive Consume to succeed") + } +} + +func TestLinkCodeConsumeInvalid(t *testing.T) { + store := auth.NewLinkCodeStore() + + _, _, ok := store.Consume("ZZZZZZ") + if ok { + t.Error("expected Consume of unknown code to fail") + } +} + +func TestLinkCodeConsumeExpired(t *testing.T) { + // We can't easily test TTL expiry without time manipulation, + // but we can verify the generate/consume flow works. + store := auth.NewLinkCodeStore() + code := store.Generate(1, "feishu") + + // Immediately consuming should work. + _, _, ok := store.Consume(code) + if !ok { + t.Error("expected immediate Consume to succeed") + } +} + +func TestLinkCodeUniqueness(t *testing.T) { + store := auth.NewLinkCodeStore() + seen := make(map[string]bool) + + for i := 0; i < 100; i++ { + code := store.Generate(int64(i), "telegram") + if seen[code] { + t.Errorf("duplicate code generated: %s", code) + } + seen[code] = true + } +} + +func TestIsLinkCode(t *testing.T) { + tests := []struct { + input string + want bool + }{ + {"ABC123", true}, + {"abcdef", true}, + {"123456", true}, + {"ABCDE", false}, // too short + {"ABCDEFG", false}, // too long + {"ABC 23", false}, // space + {"ABC-23", false}, // dash + {"", false}, + {" ", false}, + } + + for _, tt := range tests { + if got := auth.IsLinkCode(tt.input); got != tt.want { + t.Errorf("IsLinkCode(%q) = %v, want %v", tt.input, got, tt.want) + } + } +} + +func TestLinkCodeMultiplePlatforms(t *testing.T) { + store := auth.NewLinkCodeStore() + + code1 := store.Generate(1, "telegram") + code2 := store.Generate(1, "qq") + + // Both should be consumable. + _, p1, ok := store.Consume(code1) + if !ok || p1 != "telegram" { + t.Errorf("code1: ok=%v, platform=%q", ok, p1) + } + + _, p2, ok := store.Consume(code2) + if !ok || p2 != "qq" { + t.Errorf("code2: ok=%v, platform=%q", ok, p2) + } +} diff --git a/internal/channel/identity_test.go b/internal/channel/identity_test.go index 81d47f7a..96ecd7c9 100644 --- a/internal/channel/identity_test.go +++ b/internal/channel/identity_test.go @@ -2,9 +2,12 @@ package channel_test import ( "context" + "database/sql" "path/filepath" "testing" + "github.com/vaayne/anna/internal/auth" + "github.com/vaayne/anna/internal/auth/authdb" "github.com/vaayne/anna/internal/channel" "github.com/vaayne/anna/internal/config" appdb "github.com/vaayne/anna/internal/db" @@ -25,6 +28,34 @@ func setupStore(t *testing.T) config.Store { return store } +type testStores struct { + store config.Store + authStore auth.AuthStore + db *sql.DB +} + +func setupStores(t *testing.T) testStores { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "test.db") + db, err := appdb.OpenDB(dbPath) + if err != nil { + t.Fatalf("OpenDB: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + + store := config.NewDBStore(db) + if err := store.SeedDefaults(context.Background()); err != nil { + t.Fatalf("SeedDefaults: %v", err) + } + + as := authdb.New(db) + if err := auth.SeedRolesAndPolicies(context.Background(), as); err != nil { + t.Fatalf("SeedRolesAndPolicies: %v", err) + } + + return testStores{store: store, authStore: as, db: db} +} + func TestResolveUserCreatesUser(t *testing.T) { store := setupStore(t) ctx := context.Background() @@ -133,3 +164,176 @@ func TestResolveAgentGroupAssignment(t *testing.T) { t.Errorf("agentID = %q, want %q", agentID, "writer") } } + +// --- Auth-aware identity resolution tests --- + +func TestResolveUserWithAuthAutoMigrate(t *testing.T) { + ts := setupStores(t) + ctx := context.Background() + + // First call should auto-migrate: create auth_user + auth_identity. + resolved, err := channel.ResolveUserWithAuth(ctx, ts.store, ts.authStore, "12345", "telegram", "Alice") + if err != nil { + t.Fatalf("ResolveUserWithAuth: %v", err) + } + if resolved.AuthUserID == 0 { + t.Error("expected non-zero AuthUserID after auto-migration") + } + if resolved.ExternalID != "12345" { + t.Errorf("ExternalID = %q, want %q", resolved.ExternalID, "12345") + } + + // Check that auth_identity was created. + identity, err := ts.authStore.GetIdentityByPlatform(ctx, "telegram", "12345") + if err != nil { + t.Fatalf("GetIdentityByPlatform after auto-migrate: %v", err) + } + if identity.UserID != resolved.AuthUserID { + t.Errorf("identity.UserID = %d, want %d", identity.UserID, resolved.AuthUserID) + } + + // Check user role was assigned. + hasUserRole := false + for _, r := range resolved.Roles { + if r == auth.RoleUser { + hasUserRole = true + } + } + if !hasUserRole { + t.Errorf("expected user role in Roles %v", resolved.Roles) + } +} + +func TestResolveUserWithAuthLinkedIdentity(t *testing.T) { + ts := setupStores(t) + ctx := context.Background() + + // Pre-create auth user and identity. + hash, _ := auth.HashPassword("testpass1") + authUser, err := ts.authStore.CreateUser(ctx, "alice", hash) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + _ = ts.authStore.AssignRole(ctx, authUser.ID, auth.RoleAdmin) + _ = ts.authStore.AssignRole(ctx, authUser.ID, auth.RoleUser) + _, err = ts.authStore.CreateIdentity(ctx, auth.Identity{ + UserID: authUser.ID, + Platform: "telegram", + ExternalID: "99999", + Name: "Alice", + }) + if err != nil { + t.Fatalf("CreateIdentity: %v", err) + } + + // Resolve should find the linked identity. + resolved, err := channel.ResolveUserWithAuth(ctx, ts.store, ts.authStore, "99999", "telegram", "Alice") + if err != nil { + t.Fatalf("ResolveUserWithAuth: %v", err) + } + if resolved.AuthUserID != authUser.ID { + t.Errorf("AuthUserID = %d, want %d", resolved.AuthUserID, authUser.ID) + } + if len(resolved.Roles) != 2 { + t.Errorf("expected 2 roles, got %d", len(resolved.Roles)) + } +} + +func TestResolveUserWithAuthIdempotent(t *testing.T) { + ts := setupStores(t) + ctx := context.Background() + + // First call auto-migrates. + r1, err := channel.ResolveUserWithAuth(ctx, ts.store, ts.authStore, "555", "qq", "Bob") + if err != nil { + t.Fatalf("first call: %v", err) + } + + // Second call should find the existing identity. + r2, err := channel.ResolveUserWithAuth(ctx, ts.store, ts.authStore, "555", "qq", "Bob") + if err != nil { + t.Fatalf("second call: %v", err) + } + + if r1.AuthUserID != r2.AuthUserID { + t.Errorf("AuthUserID changed: %d vs %d", r1.AuthUserID, r2.AuthUserID) + } +} + +func TestTryLinkCodeSuccess(t *testing.T) { + ts := setupStores(t) + ctx := context.Background() + linkCodes := auth.NewLinkCodeStore() + + // Create an auth user. + hash, _ := auth.HashPassword("testpass1") + authUser, _ := ts.authStore.CreateUser(ctx, "bob", hash) + + // Generate a code. + code := linkCodes.Generate(authUser.ID, "telegram") + + // Try to link. + resp, ok := channel.TryLinkCode(ctx, ts.authStore, linkCodes, code, "telegram", "67890", "Bob") + if !ok { + t.Fatal("expected TryLinkCode to handle the message") + } + if resp != "Account linked successfully! Your channel account is now connected to your system user." { + t.Errorf("unexpected response: %s", resp) + } + + // Verify identity was created. + identity, err := ts.authStore.GetIdentityByPlatform(ctx, "telegram", "67890") + if err != nil { + t.Fatalf("GetIdentityByPlatform: %v", err) + } + if identity.UserID != authUser.ID { + t.Errorf("identity.UserID = %d, want %d", identity.UserID, authUser.ID) + } +} + +func TestTryLinkCodeWrongPlatform(t *testing.T) { + ts := setupStores(t) + ctx := context.Background() + linkCodes := auth.NewLinkCodeStore() + + hash, _ := auth.HashPassword("testpass1") + authUser, _ := ts.authStore.CreateUser(ctx, "charlie", hash) + + code := linkCodes.Generate(authUser.ID, "telegram") + + // Try with wrong platform. + resp, ok := channel.TryLinkCode(ctx, ts.authStore, linkCodes, code, "qq", "111", "Charlie") + if !ok { + t.Fatal("expected TryLinkCode to handle the message") + } + if resp == "" { + t.Error("expected non-empty error response") + } +} + +func TestTryLinkCodeNotACode(t *testing.T) { + ts := setupStores(t) + ctx := context.Background() + linkCodes := auth.NewLinkCodeStore() + + // Regular text should not be handled. + _, ok := channel.TryLinkCode(ctx, ts.authStore, linkCodes, "Hello, how are you?", "telegram", "111", "Test") + if ok { + t.Error("expected TryLinkCode to not handle regular text") + } +} + +func TestTryLinkCodeExpiredOrInvalid(t *testing.T) { + ts := setupStores(t) + ctx := context.Background() + linkCodes := auth.NewLinkCodeStore() + + // Try with a valid-looking but non-existent code. + resp, ok := channel.TryLinkCode(ctx, ts.authStore, linkCodes, "AB12CD", "telegram", "111", "Test") + if !ok { + t.Fatal("expected TryLinkCode to handle the message (code format matches)") + } + if resp == "" { + t.Error("expected error response for invalid code") + } +} From 5e93af4d62231b994d1f74af7aa841f9ab840f9a Mon Sep 17 00:00:00 2001 From: Vaayne Date: Fri, 20 Mar 2026 19:55:27 +0800 Subject: [PATCH 22/53] =?UTF-8?q?=F0=9F=93=9D=20docs:=20update=20tasks=20a?= =?UTF-8?q?nd=20handoff=20for=20Phase=204=20completion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .agents/sessions/2026-03-20-rbac/handoff.md | 65 +++++++++++++++++++++ .agents/sessions/2026-03-20-rbac/tasks.md | 16 ++--- 2 files changed, 73 insertions(+), 8 deletions(-) diff --git a/.agents/sessions/2026-03-20-rbac/handoff.md b/.agents/sessions/2026-03-20-rbac/handoff.md index 67d062e0..e0dc9912 100644 --- a/.agents/sessions/2026-03-20-rbac/handoff.md +++ b/.agents/sessions/2026-03-20-rbac/handoff.md @@ -178,3 +178,68 @@ - **Session expiry extension**: Each authenticated request extends the session by 7 days (rolling expiry). - **Lazy session cleanup**: Expired sessions are deleted on each middleware invocation via `DeleteExpiredSessions`. - **CORS origin**: Reads from settings table key `admin.cors_origin`. Falls back to `http://localhost:8080`. Can be configured via the settings API. + +## Phase 4: User Profile + Channel Linking + +**Status:** Complete +**Date:** 2026-03-20 +**Commits:** 4 commits on `main` + +### What was done + +1. **LinkCodeStore** (`internal/auth/linkcode.go`): + - In-memory `sync.Map`-based store for 6-char alphanumeric link codes + - `Generate(userID, platform) string` — creates code with 5-min TTL + - `Consume(code) (userID, platform, ok)` — single-use consumption with expiry check + - `IsLinkCode(s) bool` — quick format check (6 alphanumeric chars) + - Codes are uppercase hex from `crypto/rand` + +2. **Profile page** (`internal/admin/ui/pages/profile.templ`, `internal/admin/ui/static/js/pages/profile.js`): + - Password change form (current password, new password, confirm) + - Linked identities list with unlink button per identity + - Link code generation buttons for Telegram, QQ, Feishu + - Shows generated code with platform-specific instructions + - Alpine.js component with `api()` helper for all operations + +3. **Profile API handlers** (`internal/admin/profile.go`): + - `GET /api/auth/profile/identities` — list linked identities for current user + - `PUT /api/auth/profile/password` — change password (verify current, validate min 8 chars, max 72) + - `POST /api/auth/profile/link-code` — generate link code for platform (telegram/qq/feishu) + - `DELETE /api/auth/profile/identities/{id}` — unlink identity (ownership verification) + +4. **Routes and navigation** (`internal/admin/server.go`, `internal/admin/render.go`, `internal/admin/ui/navbar.templ`): + - `GET /profile` page route (accessible to all authenticated users) + - Profile API routes under `/api/auth/profile/` + - `LinkCodes()` accessor on Server for channel handlers + - Username in navbar is now a clickable link to `/profile` + - `LinkCodeStore` created once in `New()` and stored on Server + +5. **Channel link code interception** (`internal/channel/linkcode.go`, telegram/qq/feishu handlers): + - Shared `TryLinkCode()` function: checks code format, consumes, verifies platform match, creates `auth_identity` + - `WithAuth(authStore, linkCodes)` BotOption added to telegram, qq, feishu + - Each handler intercepts 6-char alphanumeric messages before command processing + - Platform mismatch detection (code for telegram sent to qq returns error) + - Already-linked accounts detected and reported + +6. **Auth-aware identity resolution** (`internal/channel/identity.go`, `internal/channel/resolved.go`): + - New `ResolvedIdentity` type with `AuthUserID` and `Roles` fields + - `ResolveUserWithAuth()`: looks up `auth_identities` first, falls back to `settings_users` + - Auto-migration: when `settings_users` record exists but no `auth_identity`, creates `auth_user` (username=`{platform}_{externalID}`, random password, `user` role) and links identity + - `ResolveWithAuth()`: full auth-aware resolution path + - `ResolvedChat` extended with `AuthUserID` and `Roles` fields + - Each channel bot uses `ResolveWithAuth` when `authStore` is configured, falls back to legacy `Resolve` + +7. **Tests**: + - `internal/auth/linkcode_test.go` — generate, consume, single-use, case-insensitive, uniqueness, IsLinkCode, multiple platforms + - `internal/admin/profile_test.go` — list identities (empty/with link), change password (success/wrong/short), generate link code (valid/invalid platform), unlink identity (own/other user), profile page route + - `internal/channel/identity_test.go` — auto-migration, linked identity lookup, idempotency, TryLinkCode (success/wrong platform/invalid code/non-code text) + - All tests pass with `-race` + +### Notes for next phases + +- **Channel bots need `WithAuth` option**: Callers that create channel bots (in `cmd/anna/gateway.go`) must pass `WithAuth(authStore, linkCodes)` to enable link code interception and auth-aware identity resolution. Without it, bots fall back to legacy behavior. +- **`LinkCodes()` accessor on Server**: The admin Server exposes its `LinkCodeStore` via `LinkCodes()` so that `gateway.go` can pass it to channel bots. +- **`ResolvedChat` extended**: Now carries `AuthUserID` and `Roles` — Phase 5 can use these for agent access enforcement. +- **Auto-migration username format**: `{platform}_{externalID}` (e.g., `telegram_12345`). Auto-migrated users get a random password and the `user` role. They can set a real password via admin UI later. +- **Backward compatibility preserved**: All existing code paths work without auth. `Resolve()` still works as before. `ResolveWithAuth()` is only called when `authStore` is non-nil. +- **settings_users still used**: Even in the auth-aware path, `store.UpsertUser()` is called for backward compat (sessions, memories still reference `settings_users.id`). The `config.User` record is still the primary user object in `ResolvedChat`. diff --git a/.agents/sessions/2026-03-20-rbac/tasks.md b/.agents/sessions/2026-03-20-rbac/tasks.md index be249dc6..9bf23e26 100644 --- a/.agents/sessions/2026-03-20-rbac/tasks.md +++ b/.agents/sessions/2026-03-20-rbac/tasks.md @@ -39,14 +39,14 @@ ## Phase 4: User Profile + Channel Linking -- [ ] 4.1 — Create `internal/auth/linkcode.go` (in-memory sync.Map, 5-min TTL) -- [ ] 4.2 — Create profile templ page (`internal/admin/ui/pages/profile.templ`) -- [ ] 4.3 — Create profile page JS (`internal/admin/ui/static/js/pages/profile.js`) -- [ ] 4.4 — Add profile API handlers (get profile, update password, link code, identities) -- [ ] 4.5 — Add route + nav link for `/profile` -- [ ] 4.6 — Modify channel handlers to intercept link-code messages -- [ ] 4.7 — Modify `identity.go`: resolve via auth_identities with auto-migration fallback -- [ ] 4.8 — Write tests +- [x] 4.1 — Create `internal/auth/linkcode.go` (in-memory sync.Map, 5-min TTL) +- [x] 4.2 — Create profile templ page (`internal/admin/ui/pages/profile.templ`) +- [x] 4.3 — Create profile page JS (`internal/admin/ui/static/js/pages/profile.js`) +- [x] 4.4 — Add profile API handlers (get profile, update password, link code, identities) +- [x] 4.5 — Add route + nav link for `/profile` +- [x] 4.6 — Modify channel handlers to intercept link-code messages +- [x] 4.7 — Modify `identity.go`: resolve via auth_identities with auto-migration fallback +- [x] 4.8 — Write tests ## Phase 5: Agent Scoping + Access Enforcement From f25f52db2b5cf35d8a9a4edaa2832140460d363f Mon Sep 17 00:00:00 2001 From: Vaayne Date: Fri, 20 Mar 2026 19:58:20 +0800 Subject: [PATCH 23/53] =?UTF-8?q?=F0=9F=90=9B=20fix:=20log=20role=20assign?= =?UTF-8?q?ment=20error=20during=20auto-migration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/channel/identity.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/internal/channel/identity.go b/internal/channel/identity.go index 2788f278..bad18b00 100644 --- a/internal/channel/identity.go +++ b/internal/channel/identity.go @@ -100,7 +100,9 @@ func ResolveUserWithAuth(ctx context.Context, store config.Store, authStore auth authUser = existing } else { // Assign user role. - _ = authStore.AssignRole(ctx, authUser.ID, auth.RoleUser) + if err := authStore.AssignRole(ctx, authUser.ID, auth.RoleUser); err != nil { + log.Error("assign user role during auto-migration", "user_id", authUser.ID, "error", err) + } } // Create the identity link. From 79de064c772507ebdfe6b9132a17cb387e045881 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Fri, 20 Mar 2026 20:31:11 +0800 Subject: [PATCH 24/53] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20Scope=20field?= =?UTF-8?q?=20to=20Agent=20struct=20and=20wire=20in=20DB=20queries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add AgentScopeSystem and AgentScopeRestricted constants. Map scope column in agentFromDB, CreateAgent, UpdateAgent, and SeedDefaults. Update sqlc queries to include scope in INSERT and UPDATE. --- internal/config/dbstore.go | 16 ++++++++++++++++ internal/config/store.go | 7 +++++++ internal/db/queries/settings_agents.sql | 5 +++-- internal/db/sqlc/settings_agents.sql.go | 9 +++++++-- 4 files changed, 33 insertions(+), 4 deletions(-) diff --git a/internal/config/dbstore.go b/internal/config/dbstore.go index c0423b7e..60daeb17 100644 --- a/internal/config/dbstore.go +++ b/internal/config/dbstore.go @@ -117,6 +117,10 @@ func (s *DBStore) CreateAgent(ctx context.Context, a Agent) error { if a.Enabled { enabled = 1 } + scope := a.Scope + if scope == "" { + scope = AgentScopeSystem + } _, err := s.q.CreateAgent(ctx, sqlc.CreateAgentParams{ ID: a.ID, Name: a.Name, @@ -125,6 +129,7 @@ func (s *DBStore) CreateAgent(ctx context.Context, a Agent) error { ModelFast: a.ModelFast, SystemPrompt: a.SystemPrompt, Workspace: a.Workspace, + Scope: scope, Enabled: enabled, }) if err != nil { @@ -138,6 +143,10 @@ func (s *DBStore) UpdateAgent(ctx context.Context, a Agent) error { if a.Enabled { enabled = 1 } + scope := a.Scope + if scope == "" { + scope = AgentScopeSystem + } err := s.q.UpdateAgent(ctx, sqlc.UpdateAgentParams{ ID: a.ID, Name: a.Name, @@ -146,6 +155,7 @@ func (s *DBStore) UpdateAgent(ctx context.Context, a Agent) error { ModelFast: a.ModelFast, SystemPrompt: a.SystemPrompt, Workspace: a.Workspace, + Scope: scope, Enabled: enabled, }) if err != nil { @@ -440,6 +450,7 @@ func (s *DBStore) SeedDefaults(ctx context.Context) error { Model: "anthropic/claude-sonnet-4-6", SystemPrompt: defaultAnnaSoul, Workspace: workspace, + Scope: AgentScopeSystem, Enabled: 1, }) if err != nil { @@ -483,6 +494,10 @@ func envFallback(dst *string, envKey string) { } func agentFromDB(r sqlc.SettingsAgent) Agent { + scope := r.Scope + if scope == "" { + scope = AgentScopeSystem + } return Agent{ ID: r.ID, Name: r.Name, @@ -491,6 +506,7 @@ func agentFromDB(r sqlc.SettingsAgent) Agent { ModelFast: r.ModelFast, SystemPrompt: r.SystemPrompt, Workspace: r.Workspace, + Scope: scope, Enabled: r.Enabled == 1, } } diff --git a/internal/config/store.go b/internal/config/store.go index 4ddfc77b..38bfd2cb 100644 --- a/internal/config/store.go +++ b/internal/config/store.go @@ -10,6 +10,12 @@ type Provider struct { BaseURL string `json:"base_url"` } +// AgentScope constants define the access scope for an agent. +const ( + AgentScopeSystem = "system" // all users can access + AgentScopeRestricted = "restricted" // only assigned users can access +) + // Agent represents an agent definition. // Model fields use {provider}/{model} format (e.g. "anthropic/claude-sonnet-4-6"). type Agent struct { @@ -20,6 +26,7 @@ type Agent struct { ModelFast string `json:"model_fast"` SystemPrompt string `json:"system_prompt"` Workspace string `json:"workspace"` + Scope string `json:"scope"` Enabled bool `json:"enabled"` } diff --git a/internal/db/queries/settings_agents.sql b/internal/db/queries/settings_agents.sql index 19049cc7..31f06836 100644 --- a/internal/db/queries/settings_agents.sql +++ b/internal/db/queries/settings_agents.sql @@ -1,6 +1,6 @@ -- name: CreateAgent :one -INSERT INTO settings_agents (id, name, model, model_strong, model_fast, system_prompt, workspace, enabled) -VALUES (?, ?, ?, ?, ?, ?, ?, ?) +INSERT INTO settings_agents (id, name, model, model_strong, model_fast, system_prompt, workspace, scope, enabled) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *; -- name: GetAgent :one @@ -20,6 +20,7 @@ UPDATE settings_agents SET model_fast = ?, system_prompt = ?, workspace = ?, + scope = ?, enabled = ?, updated_at = datetime('now') WHERE id = ?; diff --git a/internal/db/sqlc/settings_agents.sql.go b/internal/db/sqlc/settings_agents.sql.go index 90cf995d..65a2be6c 100644 --- a/internal/db/sqlc/settings_agents.sql.go +++ b/internal/db/sqlc/settings_agents.sql.go @@ -10,8 +10,8 @@ import ( ) const createAgent = `-- name: CreateAgent :one -INSERT INTO settings_agents (id, name, model, model_strong, model_fast, system_prompt, workspace, enabled) -VALUES (?, ?, ?, ?, ?, ?, ?, ?) +INSERT INTO settings_agents (id, name, model, model_strong, model_fast, system_prompt, workspace, scope, enabled) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id, name, model, model_strong, model_fast, system_prompt, workspace, scope, enabled, created_at, updated_at ` @@ -23,6 +23,7 @@ type CreateAgentParams struct { ModelFast string `json:"model_fast"` SystemPrompt string `json:"system_prompt"` Workspace string `json:"workspace"` + Scope string `json:"scope"` Enabled int64 `json:"enabled"` } @@ -35,6 +36,7 @@ func (q *Queries) CreateAgent(ctx context.Context, arg CreateAgentParams) (Setti arg.ModelFast, arg.SystemPrompt, arg.Workspace, + arg.Scope, arg.Enabled, ) var i SettingsAgent @@ -172,6 +174,7 @@ UPDATE settings_agents SET model_fast = ?, system_prompt = ?, workspace = ?, + scope = ?, enabled = ?, updated_at = datetime('now') WHERE id = ? @@ -184,6 +187,7 @@ type UpdateAgentParams struct { ModelFast string `json:"model_fast"` SystemPrompt string `json:"system_prompt"` Workspace string `json:"workspace"` + Scope string `json:"scope"` Enabled int64 `json:"enabled"` ID string `json:"id"` } @@ -196,6 +200,7 @@ func (q *Queries) UpdateAgent(ctx context.Context, arg UpdateAgentParams) error arg.ModelFast, arg.SystemPrompt, arg.Workspace, + arg.Scope, arg.Enabled, arg.ID, ) From 2d416efd4652e5ad86fcc8fa5f3af2ee0ed3ebc9 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Fri, 20 Mar 2026 20:31:25 +0800 Subject: [PATCH 25/53] =?UTF-8?q?=E2=9C=A8=20feat:=20agent=20user=20assign?= =?UTF-8?q?ment=20API,=20admin=20UI,=20and=20policy=20engine=20filtering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add GET/POST/DELETE /api/agents/{id}/users endpoints (admin-only) - Add scope dropdown and user assignment modal to agents page - Filter agent list/get by policy engine for non-admin users - Non-admin users see only system-scoped + assigned agents - Tests for scope CRUD, user assignment, and access filtering --- internal/admin/agents.go | 189 ++++++++++- internal/admin/agents_test.go | 330 ++++++++++++++++++++ internal/admin/server.go | 5 + internal/admin/ui/pages/agents.templ | 56 +++- internal/admin/ui/pages/agents_templ.go | 110 ++++--- internal/admin/ui/static/js/pages/agents.js | 83 ++++- 6 files changed, 724 insertions(+), 49 deletions(-) create mode 100644 internal/admin/agents_test.go diff --git a/internal/admin/agents.go b/internal/admin/agents.go index 1301fc47..9b720633 100644 --- a/internal/admin/agents.go +++ b/internal/admin/agents.go @@ -1,17 +1,35 @@ package admin import ( + "context" + "fmt" "net/http" + "strconv" + "github.com/vaayne/anna/internal/auth" "github.com/vaayne/anna/internal/config" ) func (s *Server) listAgents(w http.ResponseWriter, r *http.Request) { - agents, err := s.store.ListAgents(r.Context()) + ctx := r.Context() + info := UserFromContext(ctx) + + agents, err := s.store.ListAgents(ctx) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } + + // Admin sees all agents. Non-admin users see only accessible agents. + if info != nil && !info.IsAdmin { + agents, err = s.filterAccessibleAgents(ctx, info, agents) + if err != nil { + s.log.Error("filter accessible agents", "user_id", info.UserID, "error", err) + writeError(w, http.StatusInternalServerError, "failed to filter agents") + return + } + } + writeData(w, http.StatusOK, agents) } @@ -28,6 +46,13 @@ func (s *Server) createAgent(w http.ResponseWriter, r *http.Request) { if a.Name == "" { a.Name = a.ID } + if a.Scope == "" { + a.Scope = config.AgentScopeSystem + } + if a.Scope != config.AgentScopeSystem && a.Scope != config.AgentScopeRestricted { + writeError(w, http.StatusBadRequest, "scope must be 'system' or 'restricted'") + return + } if err := s.store.CreateAgent(r.Context(), a); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return @@ -36,12 +61,24 @@ func (s *Server) createAgent(w http.ResponseWriter, r *http.Request) { } func (s *Server) getAgent(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + info := UserFromContext(ctx) id := r.PathValue("id") - a, err := s.store.GetAgent(r.Context(), id) + + a, err := s.store.GetAgent(ctx, id) if err != nil { writeError(w, http.StatusNotFound, "agent not found") return } + + // Non-admin users can only access system or assigned agents. + if info != nil && !info.IsAdmin { + if !s.canAccessAgent(ctx, info, a) { + writeError(w, http.StatusForbidden, "you don't have access to this agent") + return + } + } + writeData(w, http.StatusOK, a) } @@ -56,6 +93,13 @@ func (s *Server) updateAgent(w http.ResponseWriter, r *http.Request) { if a.Name == "" { a.Name = id } + if a.Scope == "" { + a.Scope = config.AgentScopeSystem + } + if a.Scope != config.AgentScopeSystem && a.Scope != config.AgentScopeRestricted { + writeError(w, http.StatusBadRequest, "scope must be 'system' or 'restricted'") + return + } if err := s.store.UpdateAgent(r.Context(), a); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return @@ -71,3 +115,144 @@ func (s *Server) deleteAgent(w http.ResponseWriter, r *http.Request) { } writeData(w, http.StatusOK, map[string]string{"status": "deleted"}) } + +// --- Agent user assignment API (admin-only) --- + +func (s *Server) listAgentUsers(w http.ResponseWriter, r *http.Request) { + agentID := r.PathValue("id") + ctx := r.Context() + + userIDs, err := s.authStore.ListAgentUserIDs(ctx, agentID) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list agent users: "+err.Error()) + return + } + + // Resolve user details for each user ID. + type agentUser struct { + ID int64 `json:"id"` + Username string `json:"username"` + } + users := make([]agentUser, 0, len(userIDs)) + for _, uid := range userIDs { + u, err := s.authStore.GetUser(ctx, uid) + if err != nil { + continue // skip users that no longer exist + } + users = append(users, agentUser{ID: u.ID, Username: u.Username}) + } + + writeData(w, http.StatusOK, users) +} + +func (s *Server) assignAgentUser(w http.ResponseWriter, r *http.Request) { + agentID := r.PathValue("id") + ctx := r.Context() + + var body struct { + UserID int64 `json:"user_id"` + } + if err := decodeJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + if body.UserID == 0 { + writeError(w, http.StatusBadRequest, "user_id is required") + return + } + + // Verify agent exists. + if _, err := s.store.GetAgent(ctx, agentID); err != nil { + writeError(w, http.StatusNotFound, "agent not found") + return + } + + // Verify user exists. + if _, err := s.authStore.GetUser(ctx, body.UserID); err != nil { + writeError(w, http.StatusNotFound, "user not found") + return + } + + if err := s.authStore.AssignAgent(ctx, body.UserID, agentID); err != nil { + writeError(w, http.StatusInternalServerError, "failed to assign user: "+err.Error()) + return + } + + writeData(w, http.StatusOK, map[string]string{"status": "assigned"}) +} + +func (s *Server) removeAgentUser(w http.ResponseWriter, r *http.Request) { + agentID := r.PathValue("id") + userIDStr := r.PathValue("userId") + ctx := r.Context() + + userID, err := strconv.ParseInt(userIDStr, 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid user ID") + return + } + + if err := s.authStore.RemoveAgent(ctx, userID, agentID); err != nil { + writeError(w, http.StatusInternalServerError, "failed to remove user: "+err.Error()) + return + } + + writeData(w, http.StatusOK, map[string]string{"status": "removed"}) +} + +// --- Policy engine helpers --- + +// filterAccessibleAgents returns only agents the user can access (system scope + assigned). +func (s *Server) filterAccessibleAgents(ctx context.Context, info *AuthInfo, agents []config.Agent) ([]config.Agent, error) { + assignedIDs, err := s.authStore.ListUserAgentIDs(ctx, info.UserID) + if err != nil { + return nil, fmt.Errorf("list user agent IDs: %w", err) + } + + subject := auth.Subject{ + UserID: info.UserID, + Roles: info.Roles, + AgentIDs: assignedIDs, + } + + var filtered []config.Agent + for _, a := range agents { + req := auth.AccessRequest{ + Subject: subject, + Action: auth.ActionRead, + Resource: auth.Resource{ + Type: auth.ResourceAgent, + ID: a.ID, + Attrs: map[string]any{"scope": a.Scope}, + }, + } + if s.engine.Can(ctx, req) { + filtered = append(filtered, a) + } + } + return filtered, nil +} + +// canAccessAgent checks if the user can access a specific agent. +func (s *Server) canAccessAgent(ctx context.Context, info *AuthInfo, a config.Agent) bool { + assignedIDs, err := s.authStore.ListUserAgentIDs(ctx, info.UserID) + if err != nil { + s.log.Error("list user agent IDs for access check", "user_id", info.UserID, "error", err) + return false + } + + req := auth.AccessRequest{ + Subject: auth.Subject{ + UserID: info.UserID, + Roles: info.Roles, + AgentIDs: assignedIDs, + }, + Action: auth.ActionRead, + Resource: auth.Resource{ + Type: auth.ResourceAgent, + ID: a.ID, + Attrs: map[string]any{"scope": a.Scope}, + }, + } + return s.engine.Can(ctx, req) +} diff --git a/internal/admin/agents_test.go b/internal/admin/agents_test.go new file mode 100644 index 00000000..d885d823 --- /dev/null +++ b/internal/admin/agents_test.go @@ -0,0 +1,330 @@ +package admin_test + +import ( + "context" + "encoding/json" + "net/http" + "strconv" + "testing" + "time" + + "github.com/vaayne/anna/internal/auth" + "github.com/vaayne/anna/internal/config" +) + +func TestAgentScopeInCreateAndGet(t *testing.T) { + env := setupAdmin(t) + + // Create a restricted agent. + body := config.Agent{ + ID: "restricted-agent", + Name: "Restricted", + Model: "anthropic/claude-sonnet-4-6", + Workspace: "/tmp/restricted", + Scope: "restricted", + Enabled: true, + } + rr := doRequest(t, env, "POST", "/api/agents", body) + if rr.Code != http.StatusCreated { + t.Fatalf("create status = %d, want %d (body: %s)", rr.Code, http.StatusCreated, rr.Body.String()) + } + + // Verify scope is persisted. + rr = doRequest(t, env, "GET", "/api/agents/restricted-agent", nil) + if rr.Code != http.StatusOK { + t.Fatalf("get status = %d, want %d", rr.Code, http.StatusOK) + } + resp := parseResponse(t, rr) + var a config.Agent + if err := json.Unmarshal(resp.Data, &a); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if a.Scope != "restricted" { + t.Errorf("Scope = %q, want %q", a.Scope, "restricted") + } +} + +func TestAgentScopeDefaultsToSystem(t *testing.T) { + env := setupAdmin(t) + + // The seeded "anna" agent should have system scope. + rr := doRequest(t, env, "GET", "/api/agents/anna", nil) + if rr.Code != http.StatusOK { + t.Fatalf("get status = %d, want %d", rr.Code, http.StatusOK) + } + resp := parseResponse(t, rr) + var a config.Agent + if err := json.Unmarshal(resp.Data, &a); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if a.Scope != "system" { + t.Errorf("Scope = %q, want %q", a.Scope, "system") + } +} + +func TestAgentScopeInUpdate(t *testing.T) { + env := setupAdmin(t) + + // Update anna to restricted scope. + body := config.Agent{ + ID: "anna", + Name: "Anna", + Model: "anthropic/claude-sonnet-4-6", + Workspace: "/tmp/anna", + Scope: "restricted", + Enabled: true, + } + rr := doRequest(t, env, "PUT", "/api/agents/anna", body) + if rr.Code != http.StatusOK { + t.Fatalf("update status = %d, want %d (body: %s)", rr.Code, http.StatusOK, rr.Body.String()) + } + + // Verify scope persisted. + rr = doRequest(t, env, "GET", "/api/agents/anna", nil) + resp := parseResponse(t, rr) + var a config.Agent + _ = json.Unmarshal(resp.Data, &a) + if a.Scope != "restricted" { + t.Errorf("Scope = %q, want %q", a.Scope, "restricted") + } +} + +func TestAgentInvalidScope(t *testing.T) { + env := setupAdmin(t) + + body := config.Agent{ + ID: "bad-scope", + Name: "Bad", + Model: "anthropic/claude-sonnet-4-6", + Workspace: "/tmp/bad", + Scope: "invalid", + Enabled: true, + } + rr := doRequest(t, env, "POST", "/api/agents", body) + if rr.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d (body: %s)", rr.Code, http.StatusBadRequest, rr.Body.String()) + } +} + +func TestAgentUserAssignment(t *testing.T) { + env := setupAdmin(t) + ctx := context.Background() + + // Create a restricted agent. + body := config.Agent{ + ID: "secure", + Name: "Secure", + Model: "anthropic/claude-sonnet-4-6", + Workspace: "/tmp/secure", + Scope: "restricted", + Enabled: true, + } + rr := doRequest(t, env, "POST", "/api/agents", body) + if rr.Code != http.StatusCreated { + t.Fatalf("create agent: status = %d (body: %s)", rr.Code, rr.Body.String()) + } + + // Create a user to assign. + hash, _ := auth.HashPassword("userpassword") + user, err := env.authStore.CreateUser(ctx, "testuser1", hash) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + + // List users — initially empty. + rr = doRequest(t, env, "GET", "/api/agents/secure/users", nil) + if rr.Code != http.StatusOK { + t.Fatalf("list users: status = %d (body: %s)", rr.Code, rr.Body.String()) + } + resp := parseResponse(t, rr) + var users []struct { + ID int64 `json:"id"` + Username string `json:"username"` + } + _ = json.Unmarshal(resp.Data, &users) + if len(users) != 0 { + t.Errorf("expected 0 users, got %d", len(users)) + } + + // Assign user. + rr = doRequest(t, env, "POST", "/api/agents/secure/users", map[string]any{"user_id": user.ID}) + if rr.Code != http.StatusOK { + t.Fatalf("assign user: status = %d (body: %s)", rr.Code, rr.Body.String()) + } + + // Verify user appears in list. + rr = doRequest(t, env, "GET", "/api/agents/secure/users", nil) + resp = parseResponse(t, rr) + _ = json.Unmarshal(resp.Data, &users) + if len(users) != 1 { + t.Fatalf("expected 1 user, got %d", len(users)) + } + if users[0].ID != user.ID { + t.Errorf("user ID = %d, want %d", users[0].ID, user.ID) + } + if users[0].Username != "testuser1" { + t.Errorf("username = %q, want %q", users[0].Username, "testuser1") + } + + // Remove user. + rr = doRequest(t, env, "DELETE", "/api/agents/secure/users/"+strconv.FormatInt(user.ID, 10), nil) + if rr.Code != http.StatusOK { + t.Fatalf("remove user: status = %d (body: %s)", rr.Code, rr.Body.String()) + } + + // Verify user removed. + rr = doRequest(t, env, "GET", "/api/agents/secure/users", nil) + resp = parseResponse(t, rr) + _ = json.Unmarshal(resp.Data, &users) + if len(users) != 0 { + t.Errorf("expected 0 users after removal, got %d", len(users)) + } +} + +func TestAgentUserAssignmentNonAdminDenied(t *testing.T) { + env := setupAdmin(t) + ctx := context.Background() + + // Create non-admin user session. + hash, _ := auth.HashPassword("userpassword") + user, _ := env.authStore.CreateUser(ctx, "nonadmin", hash) + _ = env.authStore.AssignRole(ctx, user.ID, auth.RoleUser) + + sessionID := auth.NewSessionID() + _, _ = env.authStore.CreateSession(ctx, auth.Session{ + ID: sessionID, + UserID: user.ID, + ExpiresAt: time.Now().Add(auth.SessionDuration), + }) + + // Non-admin cannot access agent user APIs. + rr := doRequestWithSession(t, env.srv, sessionID, "GET", "/api/agents/anna/users", nil) + if rr.Code != http.StatusForbidden { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusForbidden) + } +} + +func TestNonAdminSeesOnlyAccessibleAgents(t *testing.T) { + env := setupAdmin(t) + ctx := context.Background() + + // Create a restricted agent. + body := config.Agent{ + ID: "private-agent", + Name: "Private", + Model: "anthropic/claude-sonnet-4-6", + Workspace: "/tmp/private", + Scope: "restricted", + Enabled: true, + } + rr := doRequest(t, env, "POST", "/api/agents", body) + if rr.Code != http.StatusCreated { + t.Fatalf("create agent: status = %d (body: %s)", rr.Code, rr.Body.String()) + } + + // Create non-admin user. + hash, _ := auth.HashPassword("userpassword") + user, _ := env.authStore.CreateUser(ctx, "regular", hash) + _ = env.authStore.AssignRole(ctx, user.ID, auth.RoleUser) + + sessionID := auth.NewSessionID() + _, _ = env.authStore.CreateSession(ctx, auth.Session{ + ID: sessionID, + UserID: user.ID, + ExpiresAt: time.Now().Add(auth.SessionDuration), + }) + + // Non-admin listing agents should see "anna" (system scope) but not "private-agent". + rr = doRequestWithSession(t, env.srv, sessionID, "GET", "/api/agents", nil) + if rr.Code != http.StatusOK { + t.Fatalf("list agents: status = %d", rr.Code) + } + resp := parseResponse(t, rr) + var agents []config.Agent + _ = json.Unmarshal(resp.Data, &agents) + + foundAnna := false + foundPrivate := false + for _, a := range agents { + if a.ID == "anna" { + foundAnna = true + } + if a.ID == "private-agent" { + foundPrivate = true + } + } + if !foundAnna { + t.Error("expected non-admin to see system-scoped 'anna' agent") + } + if foundPrivate { + t.Error("non-admin should not see restricted 'private-agent'") + } + + // Assign user to the restricted agent. + _ = env.authStore.AssignAgent(ctx, user.ID, "private-agent") + + // Now listing should include the assigned agent. + rr = doRequestWithSession(t, env.srv, sessionID, "GET", "/api/agents", nil) + resp = parseResponse(t, rr) + _ = json.Unmarshal(resp.Data, &agents) + + foundPrivate = false + for _, a := range agents { + if a.ID == "private-agent" { + foundPrivate = true + } + } + if !foundPrivate { + t.Error("expected assigned user to see restricted 'private-agent'") + } +} + +func TestNonAdminGetAgentAccessCheck(t *testing.T) { + env := setupAdmin(t) + ctx := context.Background() + + // Create a restricted agent. + body := config.Agent{ + ID: "secret", + Name: "Secret", + Model: "anthropic/claude-sonnet-4-6", + Workspace: "/tmp/secret", + Scope: "restricted", + Enabled: true, + } + rr := doRequest(t, env, "POST", "/api/agents", body) + if rr.Code != http.StatusCreated { + t.Fatalf("create agent: status = %d (body: %s)", rr.Code, rr.Body.String()) + } + + // Create non-admin user. + hash, _ := auth.HashPassword("userpassword") + user, _ := env.authStore.CreateUser(ctx, "regular2", hash) + _ = env.authStore.AssignRole(ctx, user.ID, auth.RoleUser) + + sessionID := auth.NewSessionID() + _, _ = env.authStore.CreateSession(ctx, auth.Session{ + ID: sessionID, + UserID: user.ID, + ExpiresAt: time.Now().Add(auth.SessionDuration), + }) + + // Non-admin can get system agent. + rr = doRequestWithSession(t, env.srv, sessionID, "GET", "/api/agents/anna", nil) + if rr.Code != http.StatusOK { + t.Fatalf("get anna: status = %d, want %d", rr.Code, http.StatusOK) + } + + // Non-admin cannot get restricted agent they're not assigned to. + rr = doRequestWithSession(t, env.srv, sessionID, "GET", "/api/agents/secret", nil) + if rr.Code != http.StatusForbidden { + t.Fatalf("get secret: status = %d, want %d", rr.Code, http.StatusForbidden) + } + + // Assign user, then they can access. + _ = env.authStore.AssignAgent(ctx, user.ID, "secret") + rr = doRequestWithSession(t, env.srv, sessionID, "GET", "/api/agents/secret", nil) + if rr.Code != http.StatusOK { + t.Fatalf("get secret after assign: status = %d, want %d", rr.Code, http.StatusOK) + } +} diff --git a/internal/admin/server.go b/internal/admin/server.go index e1c30e1e..ca1541e1 100644 --- a/internal/admin/server.go +++ b/internal/admin/server.go @@ -102,6 +102,11 @@ func New(store config.Store, authStore auth.AuthStore, engine *auth.PolicyEngine s.mux.Handle("PUT /api/agents/{id}", adminAPI(s.updateAgent)) s.mux.Handle("DELETE /api/agents/{id}", adminAPI(s.deleteAgent)) + // Agent user assignment APIs (admin-only). + s.mux.Handle("GET /api/agents/{id}/users", adminAPI(s.listAgentUsers)) + s.mux.Handle("POST /api/agents/{id}/users", adminAPI(s.assignAgentUser)) + s.mux.Handle("DELETE /api/agents/{id}/users/{userId}", adminAPI(s.removeAgentUser)) + // Channel APIs (admin-only). s.mux.Handle("GET /api/channels", adminAPI(s.listChannels)) s.mux.Handle("GET /api/channels/{platform}", adminAPI(s.getChannel)) diff --git a/internal/admin/ui/pages/agents.templ b/internal/admin/ui/pages/agents.templ index 8e5f8cc1..bf05d8e2 100644 --- a/internal/admin/ui/pages/agents.templ +++ b/internal/admin/ui/pages/agents.templ @@ -13,6 +13,7 @@ templ AgentsPage() { @click="showForm = !showForm; if(!showForm) resetForm()" class="btn btn-ghost btn-sm text-primary font-medium" x-text="showForm ? 'Cancel' : '+ Add agent'" + x-show="isAdmin" >
@@ -52,6 +53,15 @@ templ AgentsPage() { }
+ +
+ @ui.FormField("Scope") { + + } +
@ui.FormField("System Prompt") { @@ -84,8 +94,17 @@ templ AgentsPage() { :class="a.enabled ? 'badge-success' : 'badge-ghost'" x-text="a.enabled ? 'on' : 'off'" > + restricted
-
+
+
+ +
+
+
+

+ +
+ +
No users assigned.
+
+ +
+ + +
+
+ +
+
+
+
@@ -149,4 +202,3 @@ templ modelComboField(label string, field string, placeholder string) {
} - diff --git a/internal/admin/ui/pages/agents_templ.go b/internal/admin/ui/pages/agents_templ.go index b1c0c9ca..12f4378d 100644 --- a/internal/admin/ui/pages/agents_templ.go +++ b/internal/admin/ui/pages/agents_templ.go @@ -39,7 +39,7 @@ func AgentsPage() templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -137,7 +137,7 @@ func AgentsPage() templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -153,17 +153,43 @@ func AgentsPage() templ.Component { }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } return nil }) - templ_7745c5c3_Err = ui.FormField("System Prompt").Render(templ.WithChildren(ctx, templ_7745c5c3_Var5), templ_7745c5c3_Buffer) + templ_7745c5c3_Err = ui.FormField("Scope").Render(templ.WithChildren(ctx, templ_7745c5c3_Var5), templ_7745c5c3_Buffer) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Var6 := templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) { + templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context + templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W) + if !templ_7745c5c3_IsBuffer { + defer func() { + templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer) + if templ_7745c5c3_Err == nil { + templ_7745c5c3_Err = templ_7745c5c3_BufErr + } + }() + } + ctx = templ.InitializeContext(ctx) + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) + templ_7745c5c3_Err = ui.FormField("System Prompt").Render(templ.WithChildren(ctx, templ_7745c5c3_Var6), templ_7745c5c3_Buffer) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -171,7 +197,7 @@ func AgentsPage() templ.Component { if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "

No users assigned.

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } @@ -196,100 +222,100 @@ func modelComboField(label string, field string, placeholder string) templ.Compo }() } ctx = templ.InitializeContext(ctx) - templ_7745c5c3_Var6 := templ.GetChildren(ctx) - if templ_7745c5c3_Var6 == nil { - templ_7745c5c3_Var6 = templ.NopComponent + templ_7745c5c3_Var7 := templ.GetChildren(ctx) + if templ_7745c5c3_Var7 == nil { + templ_7745c5c3_Var7 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "