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..5fb568a7 --- /dev/null +++ b/.agents/sessions/2026-03-20-rbac/handoff.md @@ -0,0 +1,433 @@ +# 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. + +## 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. + +## 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`. + +## Phase 5: Agent Scoping + Access Enforcement + +**Status:** Complete +**Date:** 2026-03-20 +**Commits:** 4 commits on `main` + +### What was done + +1. **Scope field on Agent** (`internal/config/store.go`, `internal/config/dbstore.go`): + - Added `Scope string` field to `config.Agent` struct + - Added `AgentScopeSystem` and `AgentScopeRestricted` constants + - Updated `agentFromDB` helper to map the scope column (defaults to `"system"`) + - Updated `CreateAgent` and `UpdateAgent` to include scope in DB writes + - Updated sqlc queries in `internal/db/queries/settings_agents.sql` + - Updated `SeedDefaults` to set scope on the default anna agent + +2. **Agent user assignment API** (`internal/admin/agents.go`, `internal/admin/server.go`): + - `GET /api/agents/{id}/users` — list users assigned to an agent (admin-only, returns id + username) + - `POST /api/agents/{id}/users` — assign user to agent (admin-only, body: `{"user_id": N}`) + - `DELETE /api/agents/{id}/users/{userId}` — unassign user from agent (admin-only) + - Routes registered in `server.go` behind `adminOnlyMiddleware` + +3. **Policy engine integration in admin API** (`internal/admin/agents.go`): + - `listAgents`: non-admin users get filtered results — only system-scoped + assigned agents + - `getAgent`: non-admin users get 403 for restricted agents they are not assigned to + - `filterAccessibleAgents()` and `canAccessAgent()` helpers build `AccessRequest` and call `engine.Can()` + - Subject includes `AgentIDs` loaded from `ListUserAgentIDs`, resource includes `scope` attr + +4. **Admin UI for agent management** (`internal/admin/ui/pages/agents.templ`, `agents.js`): + - Scope dropdown in agent form (system / restricted) + - "restricted" badge on agent list items + - User assignment modal: shows assigned users, add/remove buttons, user dropdown + - "users" button appears on restricted agents (admin only) + - Add/edit/delete buttons only visible to admins (`isAdmin` loaded via `/api/auth/me`) + +5. **Channel-side agent access enforcement** (`internal/channel/identity.go`, `internal/channel/resolved.go`): + - New `ResolveAgentWithAuth()` function checks agent access via policy engine + - DM default agent: checks access, returns `ErrAgentAccessDenied` if denied + - Group chat agent: checks access, returns `ErrAgentAccessDenied` if denied + - Fallback path: iterates enabled agents, returns first one user can access + - `resolveWithUser()` uses `ResolveAgentWithAuth` when auth store + engine available + - Error message: "you don't have access to this agent, contact an admin" + +6. **Channel bot wiring** (`internal/channel/telegram/telegram.go`, `qq/qq.go`, `feishu/feishu.go`, `cmd/anna/gateway.go`): + - Added `engine *auth.PolicyEngine` field to all three Bot structs + - Updated `WithAuth()` signatures to accept `(authStore, engine, linkCodes)` + - Updated all `ResolveWithAuth` calls to pass engine + - `gateway.go`: auth store + engine created before bot initialization, shared across bots + admin panel + - Link code store created in gateway for channel bots + +7. **Tests**: + - `internal/admin/agents_test.go` — scope in create/get/update, invalid scope, user assignment CRUD, non-admin denied for assignment API, non-admin sees only accessible agents, non-admin get access check + - `internal/channel/access_test.go` — system agent allowed, restricted denied, restricted allowed when assigned, admin accesses all, fallback filtering, group chat denied + - All tests pass with `-race` + +### Notes for next phases + +- **`WithAuth` signature changed**: Now takes 3 args: `(authStore, engine, linkCodes)` instead of 2. Gateway.go already updated. +- **`ResolveWithAuth` signature changed**: Now takes `engine *auth.PolicyEngine` as 5th parameter. +- **Backward compatibility preserved**: When `authStore` or `engine` is nil, the legacy `ResolveAgent` is used (no access checks). The `Resolve()` path (no auth) is unchanged. +- **Agent scope values**: `"system"` (default, all users) and `"restricted"` (only assigned users). Stored in `settings_agents.scope` column. +- **Policy evaluation**: Uses built-in policies `system:user-system-agents` (scope == "system") and `system:user-assigned-agents` (agent_id in subject.agent_ids). Admin full access via `system:admin-full-access`. +- **Error handling in channels**: `ErrAgentAccessDenied` is propagated through `resolve()` and surfaces to users as "Error: you don't have access to this agent, contact an admin" in all channel bots. + +## Phase 6: Per-User Data + Skills Isolation + +**Status:** Complete +**Date:** 2026-03-20 +**Commits:** 2 commits on `main` + +### What was done + +1. **Per-user workspace directories** (`internal/agent/workspace.go`): + - `SetupUserWorkspace(agentID, basePath, userID)` — creates `workspaces/{agentID}/users/{userID}/.agents/skills/` and `workspaces/{agentID}/users/{userID}/data/` + - `UserSkillsDir(userWorkspace)` and `UserDataDir(userWorkspace)` helpers + - Existing `SetupWorkspace` preserved for agent-level workspace (backward compat) + +2. **Per-user SkillsTool** (`internal/skills/tool.go`): + - `NewTool` now takes `userID int64` as 4th parameter + - When `userID > 0`: skills path is `workspaces/{agentID}/users/{userID}/.agents/skills/` + - When `userID == 0`: uses existing `workspace/skills/` (backward compat) + - `skillsDir()` method encapsulates the path logic + - `install.go`, `list.go`, `load.go`, `remove.go` all use `t.skillsDir()` — changes cascade + +3. **LoadSkills priority chain** (`internal/agent/runner/skill.go`): + - New 5-level priority: project > **user** > agent > common > builtin + - `LoadSkills` accepts optional `userSkillsDir` variadic parameter + - `loadSkills` internal function takes explicit `userSkillsDir string` + - Agent-level workspace skills source renamed from `"user"` to `"agent"` for clarity + +4. **System prompt integration** (`internal/agent/runner/prompt.go`): + - `DBPromptParams` extended with `UserSkillsDir string` + - `BuildSystemPromptFromDB` passes user skills dir to `LoadSkills` + +5. **Per-session runner creation** (`internal/agent/factory.go`, `internal/agent/pool.go`): + - `RunnerParams` extended with `UserID int64` (`internal/agent/runner/runner.go`) + - `pool.getOrCreateRunner` passes `sess.Info.UserID` to factory + - Factory closure: when `UserID > 0`, calls `SetupUserWorkspace`, creates per-user `SkillsTool` replacing the agent-level template, sets `UserDataDir` and `WorkDir` + - `buildSessionTools` helper replaces `SkillsTool` in extra tools for per-user version + - `config.Snapshot` extended with `AgentID` field, set in `DBStore.Snapshot()` + +6. **Sandbox enforcement** (`internal/auth/sandbox.go`, `internal/agent/tool/sandbox.go`): + - `auth.ValidatePath(allowedDir, requestedPath)` — resolves symlinks via `filepath.EvalSymlinks`, checks prefix after `filepath.Clean`, handles non-existent files by resolving nearest ancestor + - `sandboxTool` wrapper in `internal/agent/tool/sandbox.go` — intercepts file path arg, validates before delegating + - `wrapWithSandbox(tool, allowedDir, pathKey)` — returns wrapped tool or original when no sandbox + - `tool.NewRegistry` accepts optional `userDataDir` variadic — wraps read/write/edit tools with sandbox, sets bash CWD to user data dir + - `GoRunnerConfig` extended with `UserDataDir string` + +7. **Agent-level skills preserved as shared** (task 6.7): + - Skills in `workspaces/{agentID}/skills/` remain as agent-level shared skills (source: `"agent"`) + - Loaded for ALL users of that agent at priority level 3 (after project and user) + - No migration needed — existing skills continue to work + +8. **Tests**: + - `internal/agent/workspace_test.go` — SetupUserWorkspace, idempotency, isolation, invalid inputs, helper functions + - `internal/auth/sandbox_test.go` — ValidatePath: within/outside dir, traversal, symlink escape, empty dir, prefix confusion, new file + - `internal/agent/tool/sandbox_test.go` — sandboxTool: allowed/blocked paths, no-sandbox passthrough, definition preservation, symlink escape, registry with/without sandbox + - `internal/skills/tool_test.go` — per-user install/remove, per-user list (shows both user + agent skills), backward compat, skillsDir() paths + - `internal/agent/runner/skill_test.go` — LoadSkills with user dir (user wins over agent), empty user dir backward compat + - All tests pass with `-race` + +### Notes for next phases + +- **`NewTool` signature changed**: Now requires 4 args: `(annaHome, workspace, cwd string, userID int64)`. All callers updated. Use `0` for agent-level/legacy behavior. +- **`LoadSkills` variadic parameter**: Accepts optional `userSkillsDir ...string`. Existing callers with 3 args continue to work. Pass user skills dir when user isolation is active. +- **`NewRegistry` variadic parameter**: Accepts optional `userDataDir ...string`. Existing callers with 1 arg work. Admin tools.go calls `NewRegistry("")` unchanged. +- **`RunnerParams.UserID`**: Added. Pool passes `sess.Info.UserID` to factory. When 0, no per-user isolation. +- **`config.Snapshot.AgentID`**: Added. Set by `DBStore.Snapshot()`. Used by factory to call `SetupUserWorkspace`. +- **`GoRunnerConfig.UserDataDir`**: Added. When non-empty, the tool registry wraps file tools with sandbox validation and sets bash CWD. +- **Skills source labels changed**: Agent-level workspace skills now have source `"agent"` (was `"user"`). User-installed skills have source `"user"`. +- **Sandbox is defense-in-depth**: Not a hard security boundary. It prevents accidental cross-user file access. Admin bypass is possible by not setting `userDataDir` (which happens when `UserID == 0`). +- **Bash tool CWD**: When `userDataDir` is set, bash commands start in the user's data directory. Without it, the system CWD or empty string is used (existing behavior). + +## Phase 7: Admin User Management + +**Status:** Complete +**Date:** 2026-03-20 +**Commits:** 1 commit on `main` + +### What was done + +1. **Auth user management API** (`internal/admin/auth_users.go`): + - `GET /api/auth/users` — list all auth users with roles, identities, timestamps + - `GET /api/auth/users/{id}` — get user detail (roles, identities, active status, timestamps) + - `PUT /api/auth/users/{id}/roles` — assign/remove roles (body: `{"role": "admin", "action": "assign"|"remove"}`) + - `GET /api/auth/users/{id}/agents` — list assigned agent IDs + - `PUT /api/auth/users/{id}/agents` — set assigned agents (body: `{"agent_ids": ["..."]}`) + - `PUT /api/auth/users/{id}/active` — activate/deactivate (body: `{"is_active": true|false}`) + - Self-protection: cannot remove own admin role, cannot deactivate own account + - Deactivation force-deletes all user sessions (force logout) + +2. **Routes** (`internal/admin/server.go`): + - All 6 new endpoints registered under `/api/auth/users/` behind `adminOnlyMiddleware` + - Legacy `/api/users` endpoints preserved unchanged for memory management + +3. **Users page** (`internal/admin/ui/pages/users.templ`, `internal/admin/ui/static/js/pages/users.js`): + - Tabbed layout: "Auth Users" (primary, default) + "User Memory" (legacy) + - Auth Users tab: lists auth_users with username, role badges (admin=primary, user=ghost), active status, linked identity badges, created timestamp + - User detail panel (modal): opens on click, shows full user info + - Status badge + activate/deactivate button + - Roles section with +admin / -admin toggle buttons + - Linked identities list (platform, external_id, name, linked_at) + - Agent assignments with add/remove management + - Metadata: created_at, updated_at + - User Memory tab: unchanged legacy settings_users list with memory management (lazy-loaded on tab switch) + +4. **Tests** (`internal/admin/auth_users_test.go`): + - `TestListAuthUsers` — list returns users with roles + - `TestGetAuthUser` / `TestGetAuthUserNotFound` — get detail, 404 handling + - `TestUpdateAuthUserRolesAssignAdmin` / `TestUpdateAuthUserRolesRemoveAdmin` — role promotion/demotion + - `TestCannotRemoveOwnAdminRole` — self-protection guard + - `TestUpdateAuthUserRolesInvalidAction` — validation + - `TestListAndUpdateAuthUserAgents` — agent assignment CRUD + - `TestUpdateAuthUserActive` — deactivate + verify session deletion + reactivate + - `TestCannotDeactivateSelf` — self-protection guard + - `TestNonAdminCannotAccessAuthUserAPIs` — all 6 endpoints return 403 for non-admin + - `TestAuthUserWithLinkedIdentities` — identity data in user response + - `TestLegacyUsersAPIStillWorks` — backward compat verification + - All tests pass with `-race` + +### Notes + +- **Backward compatibility**: Legacy `/api/users` endpoints are unchanged. The old settings_users data is still accessible via the "User Memory" tab. +- **Agent assignment approach**: Uses `PUT /api/auth/users/{id}/agents` with full replacement semantics (set desired agent_ids, handler computes diff). +- **Role management**: Only admin role toggle is exposed in the UI. The `user` role is always present (assigned on registration). +- **Users page tab state**: The "User Memory" tab lazy-loads legacy users only on first switch to avoid unnecessary API calls. +- **Session cleanup on deactivation**: When a user is deactivated, all their HTTP sessions are deleted, forcing immediate logout. diff --git a/.agents/sessions/2026-03-20-rbac/plan.md b/.agents/sessions/2026-03-20-rbac/plan.md new file mode 100644 index 00000000..27bf6842 --- /dev/null +++ b/.agents/sessions/2026-03-20-rbac/plan.md @@ -0,0 +1,453 @@ +# Plan: RBAC + ABAC Permission System for Multi-Agent Multi-User + +## Overview + +Add a hybrid RBAC + ABAC permission system to anna, enabling multi-user access with fine-grained authorization. This includes: unified user identity with channel linking, role-based access control, attribute-based policies, admin UI authentication, agent scoping, per-user data isolation, and per-user-per-agent skill installation. + +### Goals + +- Unified user identity: single account links to multiple channel identities (Telegram, QQ, Feishu, CLI) +- RBAC: extensible role system (admin, user initially) with role-based permission grants +- ABAC: policy engine evaluating subject/action/resource/context for fine-grained control +- Admin UI auth: username/password login, session-based, with route-level enforcement +- Agent scoping: system-level vs restricted agents, user-agent assignments +- Per-user data isolation within agent workspaces +- Per-user-per-agent skill installation +- API-level enforcement on all admin endpoints +- Channel-side enforcement: deny access to unassigned agents + +### Success Criteria + +- [ ] Users can register, log in, and manage their profile in admin UI +- [ ] First registered user becomes admin; subsequent default to user role +- [ ] Admin can manage roles, assign agents to users, see all data +- [ ] Regular users only see their assigned agents, own sessions, own skills +- [ ] Admin-only sections (providers, scheduler, channels, settings) are gated +- [ ] Policy engine correctly evaluates RBAC + ABAC policies with deny-overrides +- [ ] Channel identities (TG/QQ/Feishu) link to system users via code-based flow +- [ ] Skills install per (user_id, agent_id), isolated from other users +- [ ] User workspace data isolated under `workspaces/{agent_id}/users/{user_id}/` +- [ ] All existing functionality continues to work (backward compatible) +- [ ] Tests with -race, >80% coverage on new packages + +### Out of Scope + +- OAuth / SSO external providers (future enhancement) +- Fine-grained per-field permissions (e.g., can edit agent name but not model) +- Audit logging / activity trail +- API keys / token-based auth for programmatic access +- Admin UI for custom policy creation (policies managed via DB/seed for now) +- Multi-tenancy / organization-level isolation +- 2FA / TOTP + +## Technical Approach + +### Architecture + +``` +┌─────────────────────────────────────────────┐ +│ Policy Decision Point (PDP) │ +│ internal/auth/engine.go │ +│ Evaluates policies: deny-overrides │ +│ Input: Subject, Action, Resource, Context │ +│ Output: Allow / Deny │ +├─────────────────────────────────────────────┤ +│ Policy Store │ +│ internal/auth/store.go │ +│ DB-backed: auth_policies table │ +│ Built-in defaults seeded on bootstrap │ +└──────────┬──────────────────┬───────────────┘ + │ │ +┌──────────┴───────┐ ┌──────┴──────────────┐ +│ PEP: Admin UI │ │ PEP: Channel │ +│ Middleware │ │ identity.go │ +│ (HTTP auth + │ │ (agent access │ +│ route guard) │ │ check) │ +└──────────────────┘ └─────────────────────┘ +``` + +### Components + +- **`internal/auth/`** (NEW): Core auth package — types, policy engine, session management, password hashing + - `types.go` — AuthUser, Role, Policy, AccessRequest, Subject, Resource, Action types + - `engine.go` — PolicyEngine: loads policies, evaluates deny-overrides + - `session.go` — HTTP session management (cookie-based, DB-backed sessions) + - `password.go` — bcrypt password hashing/verification + - `store.go` — AuthStore interface for DB operations on auth tables +- **`internal/auth/authdb/`** (NEW): SQLite implementation of AuthStore +- **DB schema** (`internal/db/schemas/tables/`): New tables — auth_users, auth_roles, auth_user_roles, auth_identities, auth_policies, auth_user_agents, auth_sessions +- **DB queries** (`internal/db/queries/`): sqlc queries for all auth tables +- **`internal/admin/`** (MODIFY): Add auth middleware, login page, route guards, profile page with channel linking +- **`internal/channel/identity.go`** (MODIFY): Resolve channel identity → system user, check agent access +- **`internal/agent/workspace.go`** (MODIFY): Per-user workspace directories +- **`internal/skills/tool.go`** (MODIFY): Per-user skill paths +- **`internal/config/store.go`** (MODIFY): Add agent scope field to Agent struct only. Auth methods stay in separate `AuthStore` interface — do NOT expand `config.Store` with auth methods + +### Data Model + +```sql +-- System users (first-class identity, login credentials) +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')) +); + +-- Roles (extensible, seeded with admin + user) +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')) +); + +-- User ↔ Role assignments (many-to-many) +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) +); + +-- Linked channel identities (TG/QQ/Feishu/CLI → system user) +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) +); + +-- ABAC policies (JSON conditions for fine-grained control) +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 '{}', -- JSON: {"roles":["admin"]} + actions TEXT NOT NULL DEFAULT '[]', -- JSON: ["read","write"] + resources TEXT NOT NULL DEFAULT '[]', -- JSON: ["agent","provider"] + conditions TEXT NOT NULL DEFAULT '{}', -- JSON: ABAC conditions + 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')) +); + +-- User ↔ Agent access (which users can use which restricted agents) +-- No per-agent role column for now — access is binary (has access or not). +-- If per-agent roles are needed later, add a role column via migration. +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) +); + +-- HTTP sessions (cookie-based login sessions) +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')) +); +``` + +Additionally modify existing tables: +- `settings_agents`: add `scope TEXT NOT NULL DEFAULT 'system'` column + +### Policy Engine Design + +**Evaluation algorithm** (deny-overrides): +1. Collect all enabled policies +2. Filter to policies matching: subject roles/attrs, action, resource type +3. Evaluate conditions (ABAC) for each matching policy +4. If ANY matching policy has effect=deny → DENY +5. If at least one matching policy has effect=allow → ALLOW +6. No match → DENY (default deny) + +**Condition expressions** (kept simple, not a full expression language): +```json +{ + "resource.owner_id": {"eq": "subject.id"}, + "resource.scope": {"eq": "system"}, + "resource.agent_id": {"in": "subject.agent_ids"} +} +``` + +Operators: `eq`, `neq`, `in`, `not_in`, `contains`. Values can reference subject/resource attributes or be literals. + +### Seeded Roles + +| ID | Name | Description | System? | +|----|------|-------------|---------| +| `"admin"` | Admin | Full system access | Yes | +| `"user"` | User | Standard user with scoped access | Yes | + +These role IDs are referenced as string literals in policy conditions. They are seeded on bootstrap and cannot be deleted (`is_system=1`). + +### Built-in Policies (seeded on bootstrap) + +| ID | Effect | Roles | Actions | Resources | Conditions | +|----|--------|-------|---------|-----------|------------| +| `system:admin-full-access` | allow | admin | * | * | — | +| `system:user-system-agents` | allow | user | read,execute | agent | scope == 'system' | +| `system:user-assigned-agents` | allow | user | read,execute | agent | agent_id in subject.agent_ids | +| `system:user-own-sessions` | allow | user | read,write,create,delete | session | owner_id == subject.id | +| `system:user-own-data` | allow | user | read,write | user_data | owner_id == subject.id | +| `system:user-own-skills` | allow | user | read,write,create,delete | skill | owner_id == subject.id | +| `system:user-own-profile` | allow | user | read,write | user | resource.id == subject.id | +| `system:user-view-agents-list` | allow | user | read | agent_list | — (can see names of agents for UI nav) | + +### Session Security + +- **Session ID**: 32 bytes from `crypto/rand`, hex-encoded (64 chars) +- **Cookie attributes**: `HttpOnly=true`, `SameSite=Lax`, `Secure=true` (when not localhost), `Path=/` +- **Session expiry**: 7 days, extended on each authenticated request +- **Session regeneration**: New session ID on login (prevent fixation) +- **Cleanup**: Delete expired sessions on each validation check (lazy cleanup on read) + +### Password Requirements + +- Minimum 8 characters +- bcrypt cost=12 +- Never logged, never returned in API responses + +### Rate Limiting + +- Login/register endpoints: in-memory rate limiter per IP, max 10 attempts per minute +- After 5 consecutive failed logins for a username: 30-second cooldown before next attempt +- Implementation: `sync.Map` with IP → attempt count + timestamp (simple, no external deps) + +### CORS Hardening + +- Replace `Access-Control-Allow-Origin: *` with explicit origin from config (default `http://localhost:8080`) +- Add `Access-Control-Allow-Credentials: true` +- Configurable via `settings` table key `admin.cors_origin` + +### Auth Flow + +**Registration & Login:** +1. `GET /login` → login page (templ) +2. `POST /api/auth/register` → validate password (min 8 chars), create auth_user, if first user assign admin role, else user role +3. `POST /api/auth/login` → rate-limit check → verify password → create auth_session (crypto/rand ID) → set HttpOnly cookie +4. `POST /api/auth/logout` → delete session, clear cookie +5. Middleware: extract session cookie → load user + roles (delete if expired) → inject into request context + +**Channel Linking:** +1. User logged into admin UI → profile page → "Link Telegram" button +2. Backend generates a unique 6-char code, stores in-memory `sync.Map` with 5-min TTL +3. User sends code to anna Telegram bot +4. Bot handler: look up code → find auth_user → create auth_identity(user_id, telegram, tg_external_id) +5. Code is consumed (single use). If process restarts mid-linking, user just generates a new code — acceptable for MVP. + +**Channel Identity Resolution (modified):** +1. Message arrives from Telegram with external_id +2. Look up `auth_identities` for (platform=telegram, external_id=X) +3. If found → resolve to auth_user → load roles + assigned agents → check agent access +4. If not found → **fallback**: check `settings_users` for backward compat (migration period) +5. If fallback found → auto-create `auth_user` + `auth_identity` from `settings_users` record (auto-migration) +6. If neither found → reject with "Please register and link your account" message + +**Note on auto-migration (step 5):** When an existing `settings_users` record is found but no `auth_identity` exists, we auto-create an `auth_user` (username = `{platform}_{external_id}`, random password) and link the identity. This prevents breaking existing bot users. The auto-created user gets the `user` role and can later set a password via admin UI to gain full access. + +### Workspace & Skills Isolation + +**Per-user workspace:** +``` +~/.anna/workspaces/{agent_id}/ +├── users/ +│ └── {user_id}/ +│ ├── .agents/skills/ # user-installed skills +│ └── data/ # user files +└── shared/ # agent-level shared data (system prompt, etc.) +``` + +**Skills tool modification:** +- `NewTool(annaHome, workspace, cwd)` → `NewTool(annaHome, workspace, cwd, userID)` +- Skills path: `workspaces/{agentID}/users/{userID}/.agents/skills/` instead of `workspaces/{agentID}/skills/` +- Load skills: merge agent-level builtin skills + user's installed skills + +### Migration Strategy + +- **`settings_users` table**: Preserved as-is. NOT dropped or renamed. Serves as fallback during migration. +- **Auto-migration on channel message**: When an unlinked `settings_users` record is encountered, auto-create `auth_user` + `auth_identity` (see Auth Flow above). This ensures zero downtime for existing bot users. +- **`ctx_conversations.user_id`**: Currently INTEGER with no FK constraint. After migration, new sessions use `auth_users.id`. Both ID spaces can coexist. The auto-migration path ensures the `auth_users.id` for auto-migrated users is deterministic (created once, reused thereafter). +- **`ctx_agent_memory.user_id`**: Has a hard FK constraint `REFERENCES settings_users(id)`. To resolve: the Atlas migration will change this FK to reference `auth_users(id)` instead. The auto-migration path (which creates `auth_users` records from `settings_users`) must run first, so we also add a data migration step: for each `settings_users` record, create a corresponding `auth_users` record and `auth_identity`, then update `ctx_agent_memory.user_id` to the new `auth_users.id`. This is handled in the Atlas migration SQL. Alternatively, if IDs happen to match (both are AUTOINCREMENT), we can simply change the FK target without data changes — but we should NOT rely on this; the migration must be explicit. +- **`settings_users` deprecation**: After all active users have been auto-migrated (or manually linked), `settings_users` can be dropped in a future release. Not in scope for this work. +- **First admin**: First user to register via admin UI `/login` page gets admin role. No auto-created admin on startup. + +## Implementation Phases + +### Phase 1: Auth Foundation — DB Schema + Auth Package + +Core auth types, password hashing, and database schema. + +1. Create auth DB schema files in `internal/db/schemas/tables/` (files: `auth_users.sql`, `auth_roles.sql`, `auth_user_roles.sql`, `auth_identities.sql`, `auth_policies.sql`, `auth_user_agents.sql`, `auth_sessions.sql`) +2. Add `scope` column to `settings_agents` table (file: `settings_agents.sql`) +3. Generate Atlas migration: `mise run atlas:diff -- add-auth-tables` +4. Create sqlc queries for all auth tables (files: `internal/db/queries/auth_*.sql`) +5. Run `mise run generate` to regenerate sqlc +6. Create `internal/auth/types.go` — AuthUser, Role, Policy, AccessRequest, Subject, Resource, Action +7. Create `internal/auth/password.go` — bcrypt hash/verify +8. Create `internal/auth/store.go` — AuthStore interface +9. Create `internal/auth/authdb/store.go` — SQLite AuthStore implementation using sqlc +10. Write tests for password and authdb store + +### Phase 2: Policy Engine + +The ABAC policy evaluation engine. + +1. Create `internal/auth/engine.go` — PolicyEngine struct, `Can()` and `Must()` methods +2. Implement condition evaluator: parse JSON conditions, resolve subject/resource attributes, apply operators +3. Implement deny-overrides algorithm: collect matching policies → deny wins → at least one allow → default deny +4. Create `internal/auth/seed.go` — built-in default policies (the 8 system policies) +5. Integrate seed into bootstrap: call from `SeedDefaults()` or a new auth bootstrap function +6. Write comprehensive tests: policy matching, condition evaluation, deny-overrides, edge cases + +### Phase 3: Admin UI Authentication + +Login/register pages, session middleware, route guards, CORS hardening. + +1. Create `internal/auth/session.go` — session management: `crypto/rand` ID generation, create/validate/delete, cookie helpers (`HttpOnly`, `SameSite=Lax`, `Secure` when not localhost), lazy expired-session cleanup on validation +2. Create `internal/auth/ratelimit.go` — in-memory rate limiter: per-IP attempt tracking via `sync.Map`, 10 req/min limit, 30s cooldown after 5 consecutive failures per username +3. Create login/register templ page: `internal/admin/ui/pages/login.templ` +4. Create login page JS: `internal/admin/ui/static/js/pages/login.js` +5. Add auth API handlers in `internal/admin/auth.go`: register (validate min 8 char password), login (rate-limit → verify → create session → set cookie), logout, get-current-user +6. Add auth middleware in `internal/admin/middleware.go`: extract session cookie → load user+roles (delete expired) → inject into request context → 401 if unauthenticated +7. Harden CORS in `server.go`: replace `*` origin with configurable origin (default localhost), add `Allow-Credentials: true`, read from `settings` table key `admin.cors_origin` +8. Apply auth middleware to all routes in `server.go`, exempt `/login`, `/static/`, `/api/auth/*` +9. Add route guard middleware: admin-only routes (providers, scheduler, channels, settings, users management) check role → 403 if not admin +10. Modify navbar to show/hide sections based on user role (pass role via templ context) +11. Modify root redirect: unauthenticated → `/login`, authenticated user → `/agents`, admin → `/providers` +12. Write tests for auth handlers, middleware, route guards, rate limiting, CORS + +### Phase 4: User Profile + Channel Linking + +Profile page for users to manage their account and link channel identities. + +1. Create `internal/auth/linkcode.go` — in-memory `sync.Map` link code store with 5-min TTL (no DB table; restart clears codes, user regenerates — acceptable for MVP) +2. Create profile templ page: `internal/admin/ui/pages/profile.templ` +3. Create profile page JS: `internal/admin/ui/static/js/pages/profile.js` +4. Add profile API handlers: get profile, update password, generate link code, list identities, unlink identity +5. Add route + nav link for `/profile` +6. Modify channel handlers (Telegram/QQ/Feishu): intercept link-code messages, look up code, create auth_identity +7. Modify `internal/channel/identity.go`: resolve via `auth_identities` → `auth_users` instead of `settings_users` +8. Handle backward compat: if auth_identity not found, fall back to settings_users (migration period) +9. Write tests + +### Phase 5: Agent Scoping + Access Enforcement + +Agent scope field, user-agent assignments, enforcement at API + channel level. + +1. Add `scope` field to `config.Agent` struct and admin UI agent form +2. Create user-agent assignment API: `POST/DELETE /api/agents/{id}/users/{userId}` +3. Create admin UI for agent user management (on agents page: manage assigned users) +4. Integrate policy engine into admin API middleware: check `engine.Must()` on each API route +5. Integrate policy engine into channel identity resolution: check agent access before routing +6. Return "permission denied" / "please link your account" on channel access failures +7. Modify `ResolveAgent()` to filter by user's accessible agents +8. Write tests + +### Phase 6: Per-User Data + Skills Isolation + +Per-user workspace directories and per-user-per-agent skill installation. + +1. Modify `SetupWorkspace()` to create per-user directories: `workspaces/{agentID}/users/{userID}/` +2. Modify `SkillsTool` to accept `userID`, use per-user skill path +3. Modify skill load/list/install/remove to use `workspaces/{agentID}/users/{userID}/.agents/skills/` +4. Modify `internal/agent/runner/skill.go` `LoadSkills()`: add user-specific skills directory in the priority chain (project > **user** > workspace/agent-level > common > builtin) +5. Modify runner creation to pass user ID to SkillsTool and LoadSkills +6. Add sandbox enforcement for file tools: + - Create `internal/auth/sandbox.go` — `ValidatePath(userDir, requestedPath) error`: resolves symlinks via `filepath.EvalSymlinks`, checks `filepath.Clean(resolved)` has `userDir` prefix + - Modify file tools (read/write/edit) in `internal/agent/tool/`: before executing, call `ValidatePath` with user's workspace dir + - Bash tool: set `CWD` to user's data dir, prepend `cd &&` to commands (soft sandbox — defense in depth, not a security boundary for admin-level threats) + - Admin users bypass sandbox (policy engine check: if user has admin role, skip path validation) +7. Migrate existing per-agent skills: keep in `workspaces/{agentID}/skills/` as "agent-level" shared skills, loaded for all users +8. Write tests + +### Phase 7: Admin User Management + +Admin pages for managing users, roles, and agent assignments. Note: the existing `/users` page continues to work during Phases 3-6 (it shows `settings_users` data). This phase replaces it with an auth-aware version showing `auth_users`. + +1. Enhance `/users` page: show auth_users (not just settings_users), display roles, linked identities +2. Add role management: admin can promote/demote users (toggle admin role) +3. Add agent assignment management: admin can assign/unassign users to restricted agents +4. Add user detail view: see user's sessions, skills, linked identities +5. Cleanup: remove or repurpose old `settings_users` page functionality +6. Write tests + +## Testing Strategy + +- **Unit tests**: password hashing, condition evaluator, policy engine, session management +- **Integration tests**: auth store operations, policy evaluation against real DB +- **HTTP tests**: auth handlers (register/login/logout), middleware (auth check, route guard), API access control +- **Channel tests**: identity resolution with linked accounts, agent access enforcement +- **Workspace tests**: per-user directory creation, skill isolation +- **Migration test**: ensure existing data survives schema migration +- All tests run with `-race` flag +- Target >80% coverage on `internal/auth/` package + +## Risks + +| Risk | Impact | Mitigation | +|------|--------|------------| +| Breaking existing channel identity resolution | High — all bot users lose access | Backward compat fallback to settings_users during migration period | +| Schema migration on existing databases | High — data loss | Atlas-generated migration, test on copy of real DB first | +| Session management security (cookie theft, fixation) | Medium | Secure cookie flags, session expiry, regenerate on login | +| Policy engine performance (evaluate on every request) | Low — small policy set | Load policies once at startup into memory. Since custom policy UI is out of scope, policies only change on restart. If policy editing is added later, use a version counter to trigger reload. | +| Complexity creep in condition evaluator | Medium | Keep operators minimal (eq, in, contains), no nested expressions | +| Per-user workspace disk usage | Low | Only create dirs on first use, not eagerly | +| Password storage security | High | bcrypt with cost=12, never log passwords | + +## Open Questions + +All resolved — converted to assumptions: + +- **Auth mechanism**: Username + password with cookie sessions (can add OAuth later) +- **First user bootstrap**: First registered user auto-assigned admin role +- **Link code delivery**: In-memory `sync.Map` with 5-min TTL (restart clears codes, user regenerates) +- **Existing users migration**: Auto-migrated on first channel message (see Auth Flow auto-migration). `settings_users` preserved, deprecated in future release. +- **Policy caching**: Loaded once at startup. No runtime invalidation needed since custom policy UI is out of scope. + +## Review Feedback + +### Round 1 (reviewer subagent) + +Issues addressed: +1. **CORS hardening** — Added to Phase 3 step 7: replace `*` origin, add credentials header, configurable via settings +2. **Session security** — Added "Session Security" section: crypto/rand IDs, HttpOnly/SameSite/Secure cookies, 7-day expiry, regeneration on login, lazy cleanup +3. **Rate limiting** — Added "Rate Limiting" section + Phase 3 step 2: per-IP + per-username throttling +4. **`settings_users` migration path** — Expanded Migration Strategy: auto-create auth_user+auth_identity on first channel message from unlinked user, `ctx_conversations.user_id` coexistence explained +5. **Breaking change for existing bot users** — Auto-migration path prevents breakage (see Auth Flow step 5) +6. **`role` column in `auth_user_agents`** — Removed. Access is binary for now. +7. **Session cleanup** — Lazy cleanup on validation (delete expired on read) +8. **Phase 6 sandbox enforcement** — Expanded into 4 concrete sub-steps: ValidatePath with symlink resolution, file tool integration, bash CWD, admin bypass +9. **`config.Store` interface** — Clarified: auth methods stay in separate `AuthStore`, NOT added to `config.Store` + +Additional improvements from review: +- Added "Password Requirements" section (min 8 chars) +- Added "Seeded Roles" section with explicit role IDs +- Added "CORS Hardening" section +- Clarified Phase 7 ordering (existing `/users` page works during Phases 3-6) + +### Round 2 (reviewer subagent) + +All 9 Round 1 issues verified as resolved. Two new issues found: +1. **`ctx_agent_memory` FK constraint** (Medium) — `ctx_agent_memory.user_id REFERENCES settings_users(id)` conflicts with new auth_users identity. **Fixed**: Migration Strategy updated to change FK target + data migration in Atlas migration. +2. **`LoadSkills` in `runner/skill.go`** (Low) — Not mentioned in Phase 6. **Fixed**: Added as Phase 6 step 4, user-specific skills directory in priority chain. + +## Final Status + +All 7 phases complete. The RBAC + ABAC permission system is fully implemented: + +- **Phase 1**: Auth DB schema (7 tables), sqlc queries, auth types, password hashing, AuthStore interface + SQLite implementation +- **Phase 2**: Policy engine with deny-overrides, condition evaluator (eq/neq/in/not_in/contains), 8 built-in policies, 2 system roles +- **Phase 3**: Admin UI authentication — login/register, session management, rate limiting, CORS hardening, route guards, role-based navbar +- **Phase 4**: User profile page, channel identity linking (link codes), auth-aware identity resolution with auto-migration fallback +- **Phase 5**: Agent scope field (system/restricted), user-agent assignments, policy engine enforcement in admin API + channel bots +- **Phase 6**: Per-user workspace directories, per-user skill installation, sandbox enforcement for file tools +- **Phase 7**: Admin user management page — auth users list with roles/identities, role management (promote/demote), agent assignment, user detail panel, activate/deactivate, legacy memory tab preserved 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..842828fd --- /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 + +- [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 + +- [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 + +- [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 + +- [x] 5.1 — Add `scope` field to `config.Agent` struct and agent form in admin UI +- [x] 5.2 — Create user-agent assignment API (`POST/DELETE /api/agents/{id}/users/{userId}`) +- [x] 5.3 — Create admin UI for agent user management +- [x] 5.4 — Integrate policy engine into admin API middleware +- [x] 5.5 — Integrate policy engine into channel identity resolution +- [x] 5.6 — Return permission denied / link prompt on channel access failures +- [x] 5.7 — Modify `ResolveAgent()` to filter by accessible agents +- [x] 5.8 — Write tests + +## Phase 6: Per-User Data + Skills Isolation + +- [x] 6.1 — Modify `SetupWorkspace()` for per-user directories +- [x] 6.2 — Modify `SkillsTool` to accept `userID`, per-user skill path +- [x] 6.3 — Modify skill load/list/install/remove for per-user paths +- [x] 6.4 — Modify `LoadSkills()` in `runner/skill.go`: add user dir in priority chain +- [x] 6.5 — Modify runner creation to pass user ID +- [x] 6.6 — Add sandbox enforcement (`internal/auth/sandbox.go`, file tool integration) +- [x] 6.7 — Migrate existing agent-level skills as shared +- [x] 6.8 — Write tests + +## Phase 7: Admin User Management + +- [x] 7.1 — Enhance `/users` page to show auth_users, roles, linked identities +- [x] 7.2 — Add role management (admin promote/demote) +- [x] 7.3 — Add agent assignment management +- [x] 7.4 — Add user detail view (sessions, skills, identities) +- [x] 7.5 — Cleanup old settings_users page functionality +- [x] 7.6 — Write tests diff --git a/CLAUDE.md b/CLAUDE.md index 80ece54c..71d688ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ ## Overview -anna is a self-hosted AI assistant with lossless context management (LCM). Native Go runner calling LLM providers. Two interfaces: CLI chat (Bubble Tea TUI) and gateway daemon (Telegram, QQ, Feishu bots). Built-in scheduler, heartbeat monitoring, and multi-channel notifications. +anna is a self-hosted AI assistant with lossless context management (LCM). Native Go runner calling LLM providers. Two interfaces: CLI chat (Bubble Tea TUI) and server daemon (admin panel + Telegram, QQ, Feishu bots). Built-in scheduler, heartbeat monitoring, and multi-channel notifications. ## Packages @@ -12,7 +12,7 @@ Side packages: `internal/channel/` (cli, telegram, qq, feishu, notifier) → `in Admin UI: `internal/admin/` — templ + Alpine.js + daisyUI. See `internal/admin/CLAUDE.md` for details. -Config: `~/.anna/config.yaml` | Data: `~/.anna/workspace/` (memory.db, skills, identity files) +Config + Data: `~/.anna/` (anna.db, workspace/, cache/) ## Tasks @@ -23,9 +23,9 @@ mise run lint # golangci-lint mise run format # gofmt + go mod tidy mise run generate # templ generate + sqlc codegen mise run templ:watch # watch templ files + live reload proxy -mise run atlas:diff -- NAME # generate migration from schema changes -mise run atlas:hash # recalculate migration checksum -mise run atlas:validate # validate migration integrity +mise run db:diff -- NAME # generate migration from schema changes +mise run db:hash # recalculate migration checksum +mise run db:validate # validate migration integrity ``` ## Database Migrations @@ -35,7 +35,7 @@ Schema source of truth: `internal/db/schemas/tables/*.sql`. Migrations are gener **Workflow for schema changes:** 1. Edit the schema files in `internal/db/schemas/tables/` -2. Run `mise run atlas:diff -- ` to generate a new migration in `internal/db/migrations/` +2. Run `mise run db:diff -- ` to generate a new migration in `internal/db/migrations/` 3. Run `mise run generate` to regenerate sqlc 4. The migration is auto-applied on `db.OpenDB()` at startup diff --git a/README.md b/README.md index e651f84e..e060b473 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,7 @@ Or grab a binary from [Releases](https://github.com/vaayne/anna/releases), or se ### Set up ```bash -anna onboard +anna --open ``` This opens a web admin panel in your browser where you can configure everything: providers, API keys, agents, channels (Telegram, QQ, Feishu), users, scheduled jobs, and settings. All configuration is stored in `~/.anna/anna.db`. There are no YAML config files. @@ -161,21 +161,21 @@ This opens a web admin panel in your browser where you can configure everything: ```bash anna chat # Terminal chat (default agent) anna chat --agent helper # Terminal chat with a specific agent -anna gateway # Start daemon (bots + scheduler) -anna gateway --admin-port 8080 # Start daemon with admin panel +anna # Start daemon (bots + scheduler) +anna --admin-port 8080 # Start daemon with admin panel ``` -`anna chat` gives you a terminal conversation. `anna gateway` starts all your configured channels and the scheduler. Add `--admin-port` to expose the admin panel alongside the gateway for runtime configuration. +`anna chat` gives you a terminal conversation. `anna` (bare command) starts all your configured channels and the scheduler. Add `--admin-port` to expose the admin panel alongside the daemon for runtime configuration. ## CLI reference ```bash -anna onboard # Open web admin panel to configure anna +anna --open # Open web admin panel to configure anna anna chat # Interactive terminal chat anna chat --agent # Chat with a specific agent anna chat --stream # Pipe stdin, stream to stdout -anna gateway # Start daemon (bots + scheduler) -anna gateway --admin-port # Start daemon with admin panel +anna # Start daemon (bots + scheduler) +anna --admin-port # Start daemon with admin panel anna models list # List available models anna models set

# Switch model (e.g. openai/gpt-4o) anna models search # Search models diff --git a/cmd/anna/commands.go b/cmd/anna/commands.go index 9b0d8501..63787f2b 100644 --- a/cmd/anna/commands.go +++ b/cmd/anna/commands.go @@ -13,6 +13,7 @@ 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/channel" "github.com/vaayne/anna/internal/config" appdb "github.com/vaayne/anna/internal/db" @@ -27,13 +28,13 @@ func newApp() *ucli.App { Name: "anna", Usage: "A local AI assistant", Version: displayVersion(), + Flags: serverFlags(), + Action: serverAction, Commands: []*ucli.Command{ chatCommand(), - gatewayCommand(), modelsCommand(), skillsCommand(), pluginCommand(), - onboardCommand(), versionCommand(), upgradeCommand(), }, @@ -70,6 +71,12 @@ func setup(parent context.Context, gateway bool) (*setupResult, error) { return nil, fmt.Errorf("seed defaults: %w", err) } + // Seed auth policies. + authStore := appdb.NewAuthStore(db) + if err := auth.SeedPolicies(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 { @@ -191,11 +198,8 @@ func setup(parent context.Context, gateway bool) (*setupResult, error) { }) } - // Resolve a CLI user for local chat sessions. + // CLI sessions don't use auth — userID stays 0. var cliUserID int64 - if cliUser, err := channel.ResolveUser(ctx, store, "cli", "cli", "CLI User"); err == nil { - cliUserID = cliUser.ID - } return &setupResult{ ctx: ctx, diff --git a/cmd/anna/gateway.go b/cmd/anna/gateway.go index b0593007..240ae793 100644 --- a/cmd/anna/gateway.go +++ b/cmd/anna/gateway.go @@ -7,7 +7,9 @@ import ( "log/slog" "net" "net/http" + "os/exec" "os/signal" + "runtime" "strings" "syscall" "time" @@ -15,58 +17,81 @@ 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/channel" "github.com/vaayne/anna/internal/channel/feishu" "github.com/vaayne/anna/internal/channel/qq" "github.com/vaayne/anna/internal/channel/telegram" "github.com/vaayne/anna/internal/config" + appdb "github.com/vaayne/anna/internal/db" "github.com/vaayne/anna/internal/scheduler" "golang.org/x/sync/errgroup" ) -func gatewayCommand() *ucli.Command { - return &ucli.Command{ - Name: "gateway", - Usage: "Start daemon services (Telegram, etc.) based on config", - Flags: []ucli.Flag{ - &ucli.IntFlag{ - Name: "admin-port", - Usage: "Port for admin panel (0 = disabled)", - Value: 0, - }, +const defaultAdminPort = 25678 + +func serverFlags() []ucli.Flag { + return []ucli.Flag{ + &ucli.IntFlag{ + Name: "admin-port", + Usage: "Port for admin panel (0 = disabled)", + Value: defaultAdminPort, + EnvVars: []string{"PORT"}, }, - Action: func(c *ucli.Context) error { - ctx, cancel := signal.NotifyContext(c.Context, syscall.SIGINT, syscall.SIGTERM) - defer cancel() + &ucli.BoolFlag{ + Name: "open", + Usage: "Open admin panel in browser on startup", + }, + } +} - s, err := setup(ctx, true) - if err != nil { - return err - } - defer func() { _ = s.poolManager.Close() }() - defer func() { _ = s.pluginMgr.Close() }() +func serverAction(c *ucli.Context) error { + ctx, cancel := signal.NotifyContext(c.Context, syscall.SIGINT, syscall.SIGTERM) + defer cancel() - listFn := func() []channel.ModelOption { - return collectModelsFromStore(ctx, s.store, s.snap) - } - switchFn := modelSwitcher(s.snap, s.store, s.pool, s.extraTools, s.pluginMgr.Registry()) - return runGateway(s.ctx, s, listFn, switchFn, c.Int("admin-port")) - }, + s, err := setup(ctx, true) + if err != nil { + return err + } + defer func() { _ = s.poolManager.Close() }() + defer func() { _ = s.pluginMgr.Close() }() + + listFn := func() []channel.ModelOption { + return collectModelsFromStore(ctx, s.store, s.snap) } + switchFn := modelSwitcher(s.snap, s.store, s.pool, s.extraTools, s.pluginMgr.Registry()) + return runServer(s.ctx, s, listFn, switchFn, c.Int("admin-port"), c.Bool("open")) } -func runGateway(ctx context.Context, s *setupResult, listFn channel.ModelListFunc, switchFn channel.ModelSwitchFunc, adminPort int) error { +func runServer(ctx context.Context, s *setupResult, listFn channel.ModelListFunc, switchFn channel.ModelSwitchFunc, adminPort int, openBrowser bool) error { g, gctx := errgroup.WithContext(ctx) var channels []channel.Channel - // Optionally start admin panel server. + // Create auth store and policy engine for channel bots and admin panel. + as := appdb.NewAuthStore(s.db) + engine, err := auth.NewEngine(gctx, as) + if err != nil { + return fmt.Errorf("create auth engine: %w", err) + } + + // Link codes are shared between admin panel and channel bots. + linkCodes := auth.NewLinkCodeStore() + + // Start admin panel server. if adminPort > 0 { - adminSrv := admin.New(s.store, s.mem, s.db) + adminSrv := admin.New(s.store, as, engine, s.mem, s.db, linkCodes) ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", adminPort)) if err != nil { return fmt.Errorf("admin listen: %w", err) } - slog.Info("starting admin panel", "addr", ln.Addr().String()) + addr := ln.Addr().String() + slog.Info("starting admin panel", "addr", addr) + fmt.Printf("Admin panel running at http://%s\n", addr) + + if openBrowser { + launchBrowser("http://" + addr) + } + httpSrv := &http.Server{Handler: adminSrv.Handler()} g.Go(func() error { <-gctx.Done() @@ -97,7 +122,9 @@ func runGateway(ctx context.Context, s *setupResult, listFn channel.ModelListFun ChannelID: tgCfg.ChannelID, GroupMode: tgCfg.GroupMode, AllowedIDs: tgCfg.AllowedIDs, - }, s.poolManager, s.store, listFn, switchFn) + }, s.poolManager, s.store, listFn, switchFn, + telegram.WithAuth(as, engine, linkCodes), + ) if err != nil { return fmt.Errorf("create telegram bot: %w", err) } @@ -121,7 +148,9 @@ func runGateway(ctx context.Context, s *setupResult, listFn channel.ModelListFun AppSecret: qqCfg.AppSecret, GroupMode: qqCfg.GroupMode, AllowedIDs: qqCfg.AllowedIDs, - }, s.poolManager, s.store, listFn, switchFn) + }, s.poolManager, s.store, listFn, switchFn, + qq.WithAuth(as, engine, linkCodes), + ) if err != nil { return fmt.Errorf("create qq bot: %w", err) } @@ -144,7 +173,9 @@ func runGateway(ctx context.Context, s *setupResult, listFn channel.ModelListFun NotifyChat: fsCfg.NotifyChat, GroupMode: fsCfg.GroupMode, AllowedIDs: fsCfg.AllowedIDs, - }, s.poolManager, s.store, listFn, switchFn) + }, s.poolManager, s.store, listFn, switchFn, + feishu.WithAuth(as, engine, linkCodes), + ) if err != nil { return fmt.Errorf("create feishu bot: %w", err) } @@ -156,10 +187,14 @@ func runGateway(ctx context.Context, s *setupResult, listFn channel.ModelListFun } if len(channels) == 0 { - return fmt.Errorf("no gateway services configured. Run 'anna onboard' to set up channels") + if adminPort > 0 { + slog.Warn("no channel services configured; running admin panel only") + } else { + return fmt.Errorf("no services to run: no channels configured and admin panel disabled") + } } - if len(s.notifier.Channels()) == 0 { + if len(channels) > 0 && len(s.notifier.Channels()) == 0 { slog.Warn("no enabled channels have enable_notify set to true; scheduler results and heartbeat notifications will not be delivered") } @@ -173,6 +208,9 @@ func runGateway(ctx context.Context, s *setupResult, listFn channel.ModelListFun }) } + // Wire auth store into dispatcher for per-user notification routing. + s.notifier.SetAuthStore(as) + // Wire scheduler notifications and start the scheduler AFTER channels // are registered, so early-firing jobs already use the dispatcher. if s.schedulerSvc != nil { @@ -195,14 +233,14 @@ func runGateway(ctx context.Context, s *setupResult, listFn channel.ModelListFun } } - err := g.Wait() + waitErr := g.Wait() slog.Info("gateway stopped") - return err + return waitErr } // wireSchedulerNotifier overrides the scheduler callback to collect the agent response -// and broadcast it via the notification dispatcher. When a job has an AgentID, the -// corresponding pool is looked up via the PoolManager; otherwise the default pool is used. +// and dispatch it via the notification dispatcher. User-owned jobs notify only their +// owner; system jobs (user_id=0) broadcast to all channels. func wireSchedulerNotifier(schedulerSvc *scheduler.Service, poolMgr *agent.PoolManager, defaultPool *agent.Pool, dispatcher *channel.Dispatcher) { schedulerSvc.SetOnJob(func(ctx context.Context, job scheduler.Job) { pool := defaultPool @@ -227,13 +265,41 @@ func wireSchedulerNotifier(schedulerSvc *scheduler.Service, poolMgr *agent.PoolM } if result.Len() > 0 { text := fmt.Sprintf("*%s*\n\n%s", job.Name, result.String()) - if err := dispatcher.Notify(ctx, channel.Notification{Text: text}); err != nil { + n := channel.Notification{Text: text} + var err error + if job.UserID != 0 { + // User-owned job: notify only the owner. + err = dispatcher.NotifyUser(ctx, job.UserID, n) + } else { + // System job: broadcast to all channels. + err = dispatcher.Notify(ctx, n) + } + if err != nil { slog.Error("scheduler notification failed", "job_id", job.ID, "error", err) } } }) } +func launchBrowser(url string) { + var cmd *exec.Cmd + switch runtime.GOOS { + case "darwin": + cmd = exec.Command("open", url) + case "linux": + cmd = exec.Command("xdg-open", url) + case "windows": + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) + } + if cmd != nil { + if err := cmd.Start(); err != nil { + slog.Warn("failed to open browser", "error", err) + return + } + go func() { _ = cmd.Wait() }() + } +} + // --- Channel config types for JSON deserialization --- type telegramChannelConfig struct { diff --git a/cmd/anna/onboard.go b/cmd/anna/onboard.go deleted file mode 100644 index a2eb56bb..00000000 --- a/cmd/anna/onboard.go +++ /dev/null @@ -1,112 +0,0 @@ -package main - -import ( - "context" - "fmt" - "log/slog" - "net" - "net/http" - "os" - "os/exec" - "path/filepath" - "runtime" - "time" - - ucli "github.com/urfave/cli/v2" - "github.com/vaayne/anna/internal/admin" - "github.com/vaayne/anna/internal/config" - appdb "github.com/vaayne/anna/internal/db" - "github.com/vaayne/anna/internal/memory" -) - -func onboardCommand() *ucli.Command { - return &ucli.Command{ - Name: "onboard", - Usage: "Open the admin panel to set up anna", - Flags: []ucli.Flag{ - &ucli.IntFlag{ - Name: "port", - Usage: "Port to listen on (0 = random)", - Value: 0, - }, - }, - Action: func(c *ucli.Context) error { - return runOnboard(c.Context, c.Int("port")) - }, - } -} - -func runOnboard(ctx context.Context, port int) error { - // 1. Create ANNA_HOME. - home := config.AnnaHome() - if err := os.MkdirAll(home, 0o755); err != nil { - return fmt.Errorf("create anna home: %w", err) - } - - // 2. Open DB. - dbPath := filepath.Join(home, "anna.db") - db, err := appdb.OpenDB(dbPath) - if err != nil { - return fmt.Errorf("open database: %w", err) - } - defer func() { _ = db.Close() }() - - // 3. Create config Store and seed defaults. - store := config.NewDBStore(db) - if err := store.SeedDefaults(ctx); err != nil { - return fmt.Errorf("seed defaults: %w", err) - } - - // 4. 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. Listen and serve. - ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) - if err != nil { - return fmt.Errorf("listen: %w", err) - } - defer func() { _ = ln.Close() }() - - addr := ln.Addr().String() - url := "http://" + addr - fmt.Printf("Anna admin panel running at %s\n", url) - - openBrowser(url) - - httpSrv := &http.Server{Handler: srv.Handler()} - - // Shutdown on context cancellation. - go func() { - <-ctx.Done() - shutCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - _ = httpSrv.Shutdown(shutCtx) - }() - - if err := httpSrv.Serve(ln); err != nil && err != http.ErrServerClosed { - return fmt.Errorf("serve: %w", err) - } - return nil -} - -func openBrowser(url string) { - var cmd *exec.Cmd - switch runtime.GOOS { - case "darwin": - cmd = exec.Command("open", url) - case "linux": - cmd = exec.Command("xdg-open", url) - case "windows": - cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) - } - if cmd != nil { - if err := cmd.Start(); err != nil { - slog.Warn("failed to open browser", "error", err) - return - } - go func() { _ = cmd.Wait() }() - } -} diff --git a/docs/content/docs/channels/feishu.ja.md b/docs/content/docs/channels/feishu.ja.md index 1be06a3c..a27016a4 100644 --- a/docs/content/docs/channels/feishu.ja.md +++ b/docs/content/docs/channels/feishu.ja.md @@ -10,12 +10,12 @@ anna には、WebSocket 経由で接続する Feishu (Lark) ボットが含ま 2. アプリ設定で **Bot** 機能を有効にします 3. **Event Subscriptions** で `im.message.receive_v1` イベントを追加します 4. アプリ設定から App ID、App Secret、Encrypt Key、Verification Token を取得します -5. `anna onboard` を実行して管理パネルを起動します +5. `anna --open` を実行して管理パネルを起動します 6. 管理パネルで、AI プロバイダーを追加してから、アプリ認証情報を使用して Feishu チャンネルを設定します 7. ゲートウェイを起動します: ```bash -anna gateway +anna ``` すべてのチャンネル設定(認証情報、グループモード、許可された ID など)は管理パネルから管理されます。環境変数は、プロバイダー API キー(`ANTHROPIC_API_KEY`、`OPENAI_API_KEY`)と `ANNA_HOME` に制限されています。 @@ -82,7 +82,7 @@ anna gateway ## 設定リファレンス -以下のすべての設定は、`anna onboard` 管理パネルから管理されます。 +以下のすべての設定は、`anna --open` 管理パネルから管理されます。 | Field | Description | Default | | -------------------- | ----------------------------------------------- | ---------- | diff --git a/docs/content/docs/channels/feishu.md b/docs/content/docs/channels/feishu.md index 9b466087..327a5693 100644 --- a/docs/content/docs/channels/feishu.md +++ b/docs/content/docs/channels/feishu.md @@ -10,12 +10,12 @@ anna includes a Feishu (Lark) bot that connects via WebSocket (persistent connec 2. Enable the **Bot** capability in your app settings 3. Under **Event Subscriptions**, add `im.message.receive_v1` event 4. Get your App ID, App Secret, Encrypt Key, and Verification Token from the app settings -5. Run `anna onboard` to launch the admin panel +5. Run `anna --open` to launch the admin panel 6. In the admin panel: add an AI provider, then configure the Feishu channel with your app credentials -7. Start the gateway: +7. Start the daemon: ```bash -anna gateway +anna ``` All channel configuration (credentials, group mode, allowed IDs, etc.) is managed through the admin panel. Environment variables are limited to provider API keys (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`) and `ANNA_HOME`. @@ -82,7 +82,7 @@ Send these commands as text messages to the bot: ## Configuration Reference -All settings below are managed through the `anna onboard` admin panel. +All settings below are managed through the admin panel (`anna --open`). | Field | Description | Default | | -------------------- | -------------------------------------------------- | ---------- | diff --git a/docs/content/docs/channels/feishu.zh.md b/docs/content/docs/channels/feishu.zh.md index d82aa6f8..542fa1af 100644 --- a/docs/content/docs/channels/feishu.zh.md +++ b/docs/content/docs/channels/feishu.zh.md @@ -10,12 +10,12 @@ anna 包含一个通过 WebSocket 连接的 Feishu(飞书)机器人(持久 2. 在应用设置中启用**机器人**能力 3. 在**事件订阅**下,添加 `im.message.receive_v1` 事件 4. 从应用设置中获取你的 App ID、App Secret、Encrypt Key 和 Verification Token -5. 运行 `anna onboard` 启动管理面板 +5. 运行 `anna --open` 启动管理面板 6. 在管理面板中:添加一个 AI 提供商,然后使用你的应用凭据配置 Feishu 频道 7. 启动网关: ```bash -anna gateway +anna ``` 所有频道配置(凭据、群组模式、允许的 ID 等)都通过管理面板管理。环境变量仅限于提供商 API 密钥(`ANTHROPIC_API_KEY`、`OPENAI_API_KEY`)和 `ANNA_HOME`。 @@ -82,7 +82,7 @@ anna gateway ## 配置参考 -以下所有设置都通过 `anna onboard` 管理面板管理。 +以下所有设置都通过 `anna --open` 管理面板管理。 | 字段 | 描述 | 默认值 | | -------------------- | ----------------------------------------- | --------- | diff --git a/docs/content/docs/channels/qq.ja.md b/docs/content/docs/channels/qq.ja.md index ba033227..0a6fac16 100644 --- a/docs/content/docs/channels/qq.ja.md +++ b/docs/content/docs/channels/qq.ja.md @@ -7,12 +7,12 @@ anna には、WebSocket 経由で接続する QQ ボットが含まれていま ## セットアップ 1. [QQ Bot Platform](https://q.qq.com/) で QQ Bot を登録し、AppID と AppSecret を取得します -2. `anna onboard` を実行して管理パネルを起動します +2. `anna --open` を実行して管理パネルを起動します 3. 管理パネルで、AI プロバイダーを追加してから、AppID と AppSecret を使用して QQ チャンネルを設定します 4. ゲートウェイを起動します: ```bash -anna gateway +anna ``` すべてのチャンネル設定(認証情報、グループモード、許可された ID など)は管理パネルから管理されます。環境変数は、プロバイダー API キー(`ANTHROPIC_API_KEY`、`OPENAI_API_KEY`)と `ANNA_HOME` に制限されています。 @@ -69,7 +69,7 @@ QQ グループメッセージは @メンションイベント(`GROUP_AT_MESSA ## 設定リファレンス -以下のすべての設定は、`anna onboard` 管理パネルから管理されます。 +以下のすべての設定は、`anna --open` 管理パネルから管理されます。 | Field | Description | Default | | ------------- | --------------------------------------------- | ---------- | diff --git a/docs/content/docs/channels/qq.md b/docs/content/docs/channels/qq.md index 3763355f..0d6784bc 100644 --- a/docs/content/docs/channels/qq.md +++ b/docs/content/docs/channels/qq.md @@ -7,12 +7,12 @@ anna includes a QQ bot that connects via WebSocket (persistent connection, no pu ## Setup 1. Register a QQ Bot at [QQ Bot Platform](https://q.qq.com/) and get your AppID and AppSecret -2. Run `anna onboard` to launch the admin panel +2. Run `anna --open` to launch the admin panel 3. In the admin panel: add an AI provider, then configure the QQ channel with your AppID and AppSecret -4. Start the gateway: +4. Start the daemon: ```bash -anna gateway +anna ``` All channel configuration (credentials, group mode, allowed IDs, etc.) is managed through the admin panel. Environment variables are limited to provider API keys (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`) and `ANNA_HOME`. @@ -69,7 +69,7 @@ Send these commands as text messages to the bot: ## Configuration Reference -All settings below are managed through the `anna onboard` admin panel. +All settings below are managed through the admin panel (`anna --open`). | Field | Description | Default | | ------------- | ----------------------------------------------- | ---------- | diff --git a/docs/content/docs/channels/qq.zh.md b/docs/content/docs/channels/qq.zh.md index 2e231d5e..0aa89aa4 100644 --- a/docs/content/docs/channels/qq.zh.md +++ b/docs/content/docs/channels/qq.zh.md @@ -7,12 +7,12 @@ anna 包含一个通过 WebSocket 连接的 QQ 机器人(持久连接,无需 ## 设置 1. 在 [QQ 开放平台](https://q.qq.com/) 注册一个 QQ 机器人并获取你的 AppID 和 AppSecret -2. 运行 `anna onboard` 启动管理面板 +2. 运行 `anna --open` 启动管理面板 3. 在管理面板中:添加一个 AI 提供商,然后使用你的 AppID 和 AppSecret 配置 QQ 频道 4. 启动网关: ```bash -anna gateway +anna ``` 所有频道配置(凭据、群组模式、允许的 ID 等)都通过管理面板管理。环境变量仅限于提供商 API 密钥(`ANTHROPIC_API_KEY`、`OPENAI_API_KEY`)和 `ANNA_HOME`。 @@ -69,7 +69,7 @@ QQ 群组消息作为 @提及事件(`GROUP_AT_MESSAGE_CREATE`)接收。在 ## 配置参考 -以下所有设置都通过 `anna onboard` 管理面板管理。 +以下所有设置都通过 `anna --open` 管理面板管理。 | 字段 | 描述 | 默认值 | | ------------- | ----------------------------------------- | --------- | diff --git a/docs/content/docs/channels/telegram.ja.md b/docs/content/docs/channels/telegram.ja.md index b329ea57..960835d0 100644 --- a/docs/content/docs/channels/telegram.ja.md +++ b/docs/content/docs/channels/telegram.ja.md @@ -7,12 +7,12 @@ anna には、ロングポーリング経由で動作する Telegram ボット ## セットアップ 1. [@BotFather](https://t.me/BotFather) 経由でボットを作成し、ボットトークンを取得します -2. `anna onboard` を実行して管理パネルを起動します +2. `anna --open` を実行して管理パネルを起動します 3. 管理パネルで、AI プロバイダーを追加してから、ボットトークンを使用して Telegram チャンネルを設定します 4. ゲートウェイを起動します: ```bash -anna gateway +anna ``` すべてのチャンネル設定(トークン、グループモード、許可された ID など)は管理パネルから管理されます。環境変数は、プロバイダー API キー(`ANTHROPIC_API_KEY`、`OPENAI_API_KEY`)と `ANNA_HOME` に制限されています。 @@ -112,7 +112,7 @@ DM では、ユーザーのデフォルトエージェントが設定されま ## 設定リファレンス -以下のすべての設定は、`anna onboard` 管理パネルから管理されます。 +以下のすべての設定は、`anna --open` 管理パネルから管理されます。 | Field | Description | Default | | ------------- | -------------------------------------------------- | ---------- | diff --git a/docs/content/docs/channels/telegram.md b/docs/content/docs/channels/telegram.md index a475f42f..4e3956a4 100644 --- a/docs/content/docs/channels/telegram.md +++ b/docs/content/docs/channels/telegram.md @@ -7,12 +7,12 @@ anna includes a Telegram bot that runs via long polling -- no webhook or public ## Setup 1. Create a bot via [@BotFather](https://t.me/BotFather) and note the bot token -2. Run `anna onboard` to launch the admin panel +2. Run `anna --open` to launch the admin panel 3. In the admin panel: add an AI provider, then configure the Telegram channel with your bot token -4. Start the gateway: +4. Start the daemon: ```bash -anna gateway +anna ``` All channel configuration (token, group mode, allowed IDs, etc.) is managed through the admin panel. Environment variables are limited to provider API keys (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`) and `ANNA_HOME`. @@ -87,7 +87,7 @@ The bot doubles as a notification backend. Configure a default notification chat Used by: -- The `notify` agent tool (in gateway mode) +- The `notify` agent tool (in server mode) - Scheduler job result broadcasting See [notification-system.md](/docs/features/notification-system) for the full notification architecture. @@ -112,7 +112,7 @@ Users can switch models mid-conversation via an inline keyboard triggered by the ## Configuration Reference -All settings below are managed through the `anna onboard` admin panel. +All settings below are managed through the admin panel (`anna --open`). | Field | Description | Default | | ------------- | ----------------------------------------------- | ---------- | diff --git a/docs/content/docs/channels/telegram.zh.md b/docs/content/docs/channels/telegram.zh.md index ef32c6b6..87bd4837 100644 --- a/docs/content/docs/channels/telegram.zh.md +++ b/docs/content/docs/channels/telegram.zh.md @@ -7,12 +7,12 @@ anna 包含一个通过长轮询运行的 Telegram 机器人 —— 无需 webho ## 设置 1. 通过 [@BotFather](https://t.me/BotFather) 创建一个机器人并记录 bot token -2. 运行 `anna onboard` 启动管理面板 +2. 运行 `anna --open` 启动管理面板 3. 在管理面板中:添加一个 AI 提供商,然后使用你的 bot token 配置 Telegram 频道 4. 启动网关: ```bash -anna gateway +anna ``` 所有频道配置(token、群组模式、允许的 ID 等)都通过管理面板管理。环境变量仅限于提供商 API 密钥(`ANTHROPIC_API_KEY`、`OPENAI_API_KEY`)和 `ANNA_HOME`。 @@ -112,7 +112,7 @@ anna gateway ## 配置参考 -以下所有设置都通过 `anna onboard` 管理面板管理。 +以下所有设置都通过 `anna --open` 管理面板管理。 | 字段 | 描述 | 默认值 | | ------------- | ----------------------------------------- | --------- | diff --git a/docs/content/docs/core/memory-system.ja.md b/docs/content/docs/core/memory-system.ja.md index 39beceb0..9e33a15c 100644 --- a/docs/content/docs/core/memory-system.ja.md +++ b/docs/content/docs/core/memory-system.ja.md @@ -72,7 +72,7 @@ ai.Message (user/assistant/tool_result) vim internal/db/schemas/tables/conversations.sql # 2. マイグレーションを生成 -mise run atlas:diff -- add_column_name +mise run db:diff -- add_column_name # 3. sqlcを再生成 mise run generate diff --git a/docs/content/docs/core/memory-system.md b/docs/content/docs/core/memory-system.md index 62865e89..d9436e46 100644 --- a/docs/content/docs/core/memory-system.md +++ b/docs/content/docs/core/memory-system.md @@ -72,7 +72,7 @@ Engine options: `WithFreshTail(n)`, `WithLogger(log)`. vim internal/db/schemas/tables/conversations.sql # 2. Generate migration -mise run atlas:diff -- add_column_name +mise run db:diff -- add_column_name # 3. Regenerate sqlc mise run generate diff --git a/docs/content/docs/core/memory-system.zh.md b/docs/content/docs/core/memory-system.zh.md index 5563df28..33b513df 100644 --- a/docs/content/docs/core/memory-system.zh.md +++ b/docs/content/docs/core/memory-system.zh.md @@ -72,7 +72,7 @@ ai.Message (user/assistant/tool_result) vim internal/db/schemas/tables/conversations.sql # 2. 生成迁移 -mise run atlas:diff -- add_column_name +mise run db:diff -- add_column_name # 3. 重新生成 sqlc mise run generate diff --git a/docs/content/docs/core/models.ja.md b/docs/content/docs/core/models.ja.md index 3886491a..0604c478 100644 --- a/docs/content/docs/core/models.ja.md +++ b/docs/content/docs/core/models.ja.md @@ -12,11 +12,11 @@ annaの各エージェントは、データベース(`settings_agents`テー | `model_strong` | 高度な推論、複雑なタスク | | `model_fast` | 迅速な応答、シンプルなクエリ | -`model_strong`と`model_fast`の両方は、設定されていない場合は`model`にフォールバックします。これらは管理パネル(`anna onboard`)を通じてエージェントごとに設定します。 +`model_strong`と`model_fast`の両方は、設定されていない場合は`model`にフォールバックします。これらは管理パネル(`anna --open`)を通じてエージェントごとに設定します。 ## プロバイダー設定 -プロバイダーは管理パネル(`anna onboard`)を通じて設定されます。各プロバイダーは、オプションのAPIキーとベースURLを持つ`settings_providers`テーブルに保存されます。 +プロバイダーは管理パネル(`anna --open`)を通じて設定されます。各プロバイダーは、オプションのAPIキーとベースURLを持つ`settings_providers`テーブルに保存されます。 環境変数は、プロバイダーの`api_key`フィールドがデータベースで空の場合のフォールバックとして機能します: diff --git a/docs/content/docs/core/models.md b/docs/content/docs/core/models.md index e4dba662..d360ae41 100644 --- a/docs/content/docs/core/models.md +++ b/docs/content/docs/core/models.md @@ -12,11 +12,11 @@ Each agent in anna has three model fields, stored in the database (`settings_age | `model_strong` | Heavy reasoning, complex tasks | | `model_fast` | Quick responses, simple queries | -Both `model_strong` and `model_fast` fall back to `model` when not set. Configure these per-agent through the admin panel (`anna onboard`). +Both `model_strong` and `model_fast` fall back to `model` when not set. Configure these per-agent through the admin panel (`anna --open`). ## Provider Setup -Providers are configured through the admin panel (`anna onboard`). Each provider is stored in the `settings_providers` table with an optional API key and base URL. +Providers are configured through the admin panel (`anna --open`). Each provider is stored in the `settings_providers` table with an optional API key and base URL. Environment variables serve as fallbacks when a provider's `api_key` field is empty in the database: diff --git a/docs/content/docs/core/models.zh.md b/docs/content/docs/core/models.zh.md index cb68f70c..b301cd08 100644 --- a/docs/content/docs/core/models.zh.md +++ b/docs/content/docs/core/models.zh.md @@ -12,11 +12,11 @@ anna 中的每个代理都有三个模型字段,存储在数据库(`settings | `model_strong` | 重度推理、复杂任务 | | `model_fast` | 快速响应、简单查询 | -`model_strong` 和 `model_fast` 在未设置时都回退到 `model`。通过 admin 面板(`anna onboard`)按代理配置这些。 +`model_strong` 和 `model_fast` 在未设置时都回退到 `model`。通过 admin 面板(`anna --open`)按代理配置这些。 ## 提供商设置 -提供商通过 admin 面板(`anna onboard`)配置。每个提供商都存储在 `settings_providers` 表中,带有可选的 API 密钥和基础 URL。 +提供商通过 admin 面板(`anna --open`)配置。每个提供商都存储在 `settings_providers` 表中,带有可选的 API 密钥和基础 URL。 当提供商的 `api_key` 字段在数据库中为空时,环境变量作为回退: diff --git a/docs/content/docs/features/scheduler-system.ja.md b/docs/content/docs/features/scheduler-system.ja.md index 46fc338e..8d17c516 100644 --- a/docs/content/docs/features/scheduler-system.ja.md +++ b/docs/content/docs/features/scheduler-system.ja.md @@ -190,7 +190,7 @@ type Job struct { - **every** -- Go durationとしてのポーリング間隔(例: `10m`) - **file** -- ハートビートファイルへのパス、絶対パスでない限りワークスペースからの相対パス(例: `HEARTBEAT.md`) -ハートビートは`anna gateway`モードでのみ実行されます。コストを最小限に抑えるため、ゲート判断にはファストモデルが使用されます。 +ハートビートはゲートウェイモード(`anna`)でのみ実行されます。コストを最小限に抑えるため、ゲート判断にはファストモデルが使用されます。 ## 配線 diff --git a/docs/content/docs/features/scheduler-system.md b/docs/content/docs/features/scheduler-system.md index aba02ef2..36694ce9 100644 --- a/docs/content/docs/features/scheduler-system.md +++ b/docs/content/docs/features/scheduler-system.md @@ -105,7 +105,7 @@ Each scheduled job's session behavior is controlled by its `session_mode`: Scheduler configuration is managed through the admin panel. Settings are stored in the `settings` table in the database. Enable or disable the scheduler and configure its behavior from the admin panel UI. -Scheduler is only active when: +Scheduler is only active in server mode (`anna`) when: - The scheduler is enabled in the admin panel settings - `runner.type` is `go` (the Pi runner does not support custom tools) @@ -190,7 +190,7 @@ Heartbeat settings are configured through the admin panel. The following paramet - **every** -- poll interval as a Go duration (e.g. `10m`) - **file** -- path to the heartbeat file, relative to workspace unless absolute (e.g. `HEARTBEAT.md`) -Heartbeat only runs in `anna gateway` mode. The fast model is used for the gate decision to minimize cost. +Heartbeat only runs in server mode (`anna`). The fast model is used for the gate decision to minimize cost. ## Wiring diff --git a/docs/content/docs/features/scheduler-system.zh.md b/docs/content/docs/features/scheduler-system.zh.md index a8163931..22e9870b 100644 --- a/docs/content/docs/features/scheduler-system.zh.md +++ b/docs/content/docs/features/scheduler-system.zh.md @@ -190,7 +190,7 @@ type Job struct { - **every**——轮询间隔,作为 Go duration(例如 `10m`) - **file**——心跳文件路径,相对于工作空间,除非是绝对路径(例如 `HEARTBEAT.md`) -心跳仅在 `anna gateway` 模式下运行。快速模型用于门控决策以最小化成本。 +心跳仅在 `anna` 守护进程模式下运行。快速模型用于门控决策以最小化成本。 ## 连接 diff --git a/docs/content/docs/getting-started/configuration.ja.md b/docs/content/docs/getting-started/configuration.ja.md index 004ddf7e..18fc8f4d 100644 --- a/docs/content/docs/getting-started/configuration.ja.md +++ b/docs/content/docs/getting-started/configuration.ja.md @@ -2,7 +2,7 @@ title: 設定 --- -すべての設定は、`~/.anna/anna.db`にある単一のSQLiteデータベースに保存されます。YAMLの設定ファイルはありません。設定をセットアップまたは変更するには、`anna onboard`を実行してWeb管理パネルを開きます。 +すべての設定は、`~/.anna/anna.db`にある単一のSQLiteデータベースに保存されます。YAMLの設定ファイルはありません。設定をセットアップまたは変更するには、`anna --open`を実行してWeb管理パネルを開きます。 ホームディレクトリのデフォルトは`~/.anna`で、環境変数`ANNA_HOME`を設定することで変更できます。 @@ -166,7 +166,7 @@ JSONオブジェクトとして`settings`テーブルの`runner`キーに保存 | `ANTHROPIC_API_KEY` | AnthropicプロバイダーのフォールバックAPIキー | | `OPENAI_API_KEY` | OpenAIプロバイダーのフォールバックAPIキー | -その他のすべての設定は、管理パネル(`anna onboard`)またはデータベースを直接介して設定する必要があります。 +その他のすべての設定は、管理パネル(`anna --open`)またはデータベースを直接介して設定する必要があります。 ## メモリデフォルト @@ -180,7 +180,7 @@ JSONオブジェクトとして`settings`テーブルの`runner`キーに保存 ## ハートビート -ハートビートは`anna gateway`でのみ実行されます。設定は`settings`テーブルの`heartbeat`キーに保存されます。各ティックでは、最初に高速モデルを使用して`skip`または`run`を決定し、`run`の決定のみがメインハートビートセッションに送信され、その後ノティファイアを通じて配信されます。指示はエージェントの`HEARTBEAT.md`ファイルから読み取られます。 +ハートビートはゲートウェイモード(`anna`)でのみ実行されます。設定は`settings`テーブルの`heartbeat`キーに保存されます。各ティックでは、最初に高速モデルを使用して`skip`または`run`を決定し、`run`の決定のみがメインハートビートセッションに送信され、その後ノティファイアを通じて配信されます。指示はエージェントの`HEARTBEAT.md`ファイルから読み取られます。 ## プラグイン diff --git a/docs/content/docs/getting-started/configuration.md b/docs/content/docs/getting-started/configuration.md index 162d2fdf..66a1da2b 100644 --- a/docs/content/docs/getting-started/configuration.md +++ b/docs/content/docs/getting-started/configuration.md @@ -2,7 +2,7 @@ title: Configuration --- -All configuration is stored in a single SQLite database at `~/.anna/anna.db`. There are no YAML config files. To set up or modify your configuration, run `anna onboard` to open the web admin panel. +All configuration is stored in a single SQLite database at `~/.anna/anna.db`. There are no YAML config files. To set up or modify your configuration, run `anna --open` to open the web admin panel. The home directory defaults to `~/.anna` and can be changed by setting the `ANNA_HOME` environment variable. @@ -166,7 +166,7 @@ The old `ANNA_*` prefix overrides for all config fields are removed. Only the fo | `ANTHROPIC_API_KEY` | Fallback API key for the Anthropic provider | | `OPENAI_API_KEY` | Fallback API key for the OpenAI provider | -All other configuration must be set through the admin panel (`anna onboard`) or directly in the database. +All other configuration must be set through the admin panel (`anna --open`) or directly in the database. ## Memory Defaults @@ -180,7 +180,7 @@ Lossless Context Management settings are currently hardcoded defaults. They will ## Heartbeat -Heartbeat only runs in `anna gateway`. Configuration is stored in the `settings` table under the `heartbeat` key. Each tick first uses the fast model to decide `skip` vs `run`, and only `run` decisions are sent into the main heartbeat session and then delivered through the notifier. Instructions are read from the agent's `HEARTBEAT.md` file. +Heartbeat only runs in server mode (`anna`). Configuration is stored in the `settings` table under the `heartbeat` key. Each tick first uses the fast model to decide `skip` vs `run`, and only `run` decisions are sent into the main heartbeat session and then delivered through the notifier. Instructions are read from the agent's `HEARTBEAT.md` file. ## Plugins diff --git a/docs/content/docs/getting-started/configuration.zh.md b/docs/content/docs/getting-started/configuration.zh.md index ab67b92a..f41a2d64 100644 --- a/docs/content/docs/getting-started/configuration.zh.md +++ b/docs/content/docs/getting-started/configuration.zh.md @@ -2,7 +2,7 @@ title: 配置 --- -所有配置都存储在一个单独的 SQLite 数据库中,位于 `~/.anna/anna.db`。没有 YAML 配置文件。要设置或修改配置,请运行 `anna onboard` 打开 Web 管理面板。 +所有配置都存储在一个单独的 SQLite 数据库中,位于 `~/.anna/anna.db`。没有 YAML 配置文件。要设置或修改配置,请运行 `anna --open` 打开 Web 管理面板。 主目录默认为 `~/.anna`,可以通过设置 `ANNA_HOME` 环境变量来更改。 @@ -166,7 +166,7 @@ title: 配置 | `ANTHROPIC_API_KEY` | Anthropic 提供商的备用 API 密钥 | | `OPENAI_API_KEY` | OpenAI 提供商的备用 API 密钥 | -所有其他配置必须通过管理面板(`anna onboard`)或直接在数据库中设置。 +所有其他配置必须通过管理面板(`anna --open`)或直接在数据库中设置。 ## 记忆默认设置 @@ -180,7 +180,7 @@ title: 配置 ## 心跳 -心跳仅在 `anna gateway` 中运行。配置存储在 `settings` 表中,键为 `heartbeat`。每次心跳首先使用快速模型决定 `skip` 还是 `run`,只有 `run` 决策会被发送到主心跳会话,然后通过通知器发送。指令从 agent 的 `HEARTBEAT.md` 文件中读取。 +心跳仅在 `anna` 守护进程中运行。配置存储在 `settings` 表中,键为 `heartbeat`。每次心跳首先使用快速模型决定 `skip` 还是 `run`,只有 `run` 决策会被发送到主心跳会话,然后通过通知器发送。指令从 agent 的 `HEARTBEAT.md` 文件中读取。 ## 插件 diff --git a/docs/content/docs/getting-started/deployment.ja.md b/docs/content/docs/getting-started/deployment.ja.md index b43c3310..b2311481 100644 --- a/docs/content/docs/getting-started/deployment.ja.md +++ b/docs/content/docs/getting-started/deployment.ja.md @@ -29,10 +29,10 @@ cd anna && go build -o anna . ### 実行 -onboardコマンドを実行して管理パネルを開き、anna(プロバイダー、チャネル、エージェントなど)を設定します: +Web管理パネルを開き、anna(プロバイダー、チャネル、エージェントなど)を設定します: ```bash -anna onboard +anna --open ``` これにより、APIキー、チャネル、エージェントプロファイルを設定するローカルWebUIが起動します。すべての設定は`~/.anna/anna.db`に保存されます -- 手動での設定ファイルは不要です。 @@ -40,13 +40,13 @@ anna onboard gatewayデーモンを起動します: ```bash -anna gateway +anna ``` gatewayと並行して管理パネルを提供する(ランタイム設定変更のため)には: ```bash -anna gateway --admin-port 8080 +anna --admin-port 8080 ``` または、対話型CLIを使用します: @@ -70,14 +70,14 @@ anna upgrade --install-dir "$HOME/.local/bin" ```ini # /etc/systemd/system/anna.service [Unit] -Description=anna gateway +Description=anna server After=network.target [Service] Type=simple User=anna WorkingDirectory=/home/anna -ExecStart=/usr/local/bin/anna gateway --admin-port 8080 +ExecStart=/usr/local/bin/anna --admin-port 8080 Restart=on-failure RestartSec=5 @@ -93,7 +93,7 @@ WantedBy=multi-user.target sudo systemctl enable --now anna ``` -すべての設定(チャネル、エージェント、スケジューラジョブ)は`anna.db`に保存されます。管理には`anna onboard`または管理パネルを使用します。 +すべての設定(チャネル、エージェント、スケジューラジョブ)は`anna.db`に保存されます。管理には`anna --open`または管理パネルを使用します。 ## Docker @@ -109,14 +109,14 @@ sudo systemctl enable --now anna ### クイックスタート -まず、onboardを実行してannaを設定します: +まず、Web管理パネルを開いてannaを設定します: ```bash docker run -it --rm \ -v ~/.anna:/home/nonroot/.anna \ -p 8080:8080 \ ghcr.io/vaayne/anna:latest \ - anna onboard + anna --open ``` 次にgatewayを起動します: @@ -150,7 +150,7 @@ services: docker compose up -d ``` -初期設定を実行するには、`docker compose exec anna anna onboard`を使用するか、`--admin-port 8080`でgatewayを起動してWebUIを介して設定します。 +初期設定を実行するには、`docker compose exec anna anna --open`を使用するか、`--admin-port 8080`でgatewayを起動してWebUIを介して設定します。 ### ローカルでビルド @@ -177,7 +177,7 @@ docker buildx build --platform linux/amd64,linux/arm64 -t anna . ## 環境変数 -設定は管理パネル(`anna onboard`または`--admin-port`経由)を通じて管理されます。サポートされる環境変数は少数のみです: +設定は管理パネル(`anna --open`または`--admin-port`経由)を通じて管理されます。サポートされる環境変数は少数のみです: | 変数 | 必須 | 説明 | | ------------------- | ------ | --------------------------------------------- | @@ -193,7 +193,7 @@ gatewayはstdoutにログを出力します。実行中であることを確認 ```bash # バイナリ -anna gateway # ログがターミナルに表示されます +anna # ログがターミナルに表示されます # Docker docker logs anna diff --git a/docs/content/docs/getting-started/deployment.md b/docs/content/docs/getting-started/deployment.md index e4d62ba5..cfe2eb9c 100644 --- a/docs/content/docs/getting-started/deployment.md +++ b/docs/content/docs/getting-started/deployment.md @@ -29,24 +29,24 @@ cd anna && go build -o anna . ### Running -Run the onboard command to open the admin panel and configure anna (providers, channels, agents, etc.): +Run the setup command to open the admin panel and configure anna (providers, channels, agents, etc.): ```bash -anna onboard +anna --open ``` This starts a local web UI where you set up API keys, channels, and agent profiles. All configuration is stored in `~/.anna/anna.db` -- no manual config files needed. -Start the gateway daemon: +Start the daemon: ```bash -anna gateway +anna ``` -To serve the admin panel alongside the gateway (for runtime config changes): +To serve the admin panel alongside the daemon (for runtime config changes): ```bash -anna gateway --admin-port 8080 +anna --admin-port 8080 ``` Or use the interactive CLI: @@ -70,14 +70,14 @@ anna upgrade --install-dir "$HOME/.local/bin" ```ini # /etc/systemd/system/anna.service [Unit] -Description=anna gateway +Description=anna server After=network.target [Service] Type=simple User=anna WorkingDirectory=/home/anna -ExecStart=/usr/local/bin/anna gateway --admin-port 8080 +ExecStart=/usr/local/bin/anna --admin-port 8080 Restart=on-failure RestartSec=5 @@ -93,7 +93,7 @@ WantedBy=multi-user.target sudo systemctl enable --now anna ``` -All configuration (channels, agents, scheduler jobs) is stored in `anna.db`. Use `anna onboard` or the admin panel to manage it. +All configuration (channels, agents, scheduler jobs) is stored in `anna.db`. Use `anna --open` or the admin panel to manage it. ## Docker @@ -109,17 +109,17 @@ Images are published to `ghcr.io/vaayne/anna` for `linux/amd64` and `linux/arm64 ### Quick Start -First, run onboard to configure anna: +First, run setup to configure anna: ```bash docker run -it --rm \ -v ~/.anna:/home/nonroot/.anna \ -p 8080:8080 \ ghcr.io/vaayne/anna:latest \ - anna onboard + anna --open ``` -Then start the gateway: +Then start the daemon: ```bash docker run -d \ @@ -150,7 +150,7 @@ services: docker compose up -d ``` -To run initial setup, use `docker compose exec anna anna onboard` or start the gateway with `--admin-port 8080` and configure via the web UI. +To run initial setup, use `docker compose exec anna anna --open` or start the daemon with `--admin-port 8080` and configure via the web UI. ### Build Locally @@ -177,7 +177,7 @@ The `anna.db` file is the only critical data to back up. It contains all configu ## Environment Variables -Configuration is managed through the admin panel (via `anna onboard` or `--admin-port`). Only a small set of environment variables is supported: +Configuration is managed through the admin panel (via `anna --open` or `--admin-port`). Only a small set of environment variables is supported: | Variable | Required | Description | | ------------------- | -------- | --------------------------------------- | @@ -189,11 +189,11 @@ Configuration is managed through the admin panel (via `anna onboard` or `--admin ## Health Check -The gateway logs to stdout. Verify it is running: +The daemon logs to stdout. Verify it is running: ```bash # Binary -anna gateway # Logs appear in terminal +anna # Logs appear in terminal # Docker docker logs anna diff --git a/docs/content/docs/getting-started/deployment.zh.md b/docs/content/docs/getting-started/deployment.zh.md index 004e5a87..91d766d3 100644 --- a/docs/content/docs/getting-started/deployment.zh.md +++ b/docs/content/docs/getting-started/deployment.zh.md @@ -32,7 +32,7 @@ cd anna && go build -o anna . 运行 onboard 命令打开管理面板并配置 anna(提供商、频道、agents 等): ```bash -anna onboard +anna --open ``` 这会启动一个本地 Web UI,您可以在其中设置 API 密钥、频道和 agent 配置。所有配置都存储在 `~/.anna/anna.db` 中 —— 无需手动配置文件。 @@ -40,13 +40,13 @@ anna onboard 启动网关守护进程: ```bash -anna gateway +anna ``` 要在网关运行时同时提供管理面板服务(用于运行时配置更改): ```bash -anna gateway --admin-port 8080 +anna --admin-port 8080 ``` 或使用交互式 CLI: @@ -70,14 +70,14 @@ anna upgrade --install-dir "$HOME/.local/bin" ```ini # /etc/systemd/system/anna.service [Unit] -Description=anna gateway +Description=anna server After=network.target [Service] Type=simple User=anna WorkingDirectory=/home/anna -ExecStart=/usr/local/bin/anna gateway --admin-port 8080 +ExecStart=/usr/local/bin/anna --admin-port 8080 Restart=on-failure RestartSec=5 @@ -93,7 +93,7 @@ WantedBy=multi-user.target sudo systemctl enable --now anna ``` -所有配置(频道、agents、调度器任务)都存储在 `anna.db` 中。使用 `anna onboard` 或管理面板来管理配置。 +所有配置(频道、agents、调度器任务)都存储在 `anna.db` 中。使用 `anna --open` 来访问管理面板管理配置。 ## Docker @@ -116,7 +116,7 @@ docker run -it --rm \ -v ~/.anna:/home/nonroot/.anna \ -p 8080:8080 \ ghcr.io/vaayne/anna:latest \ - anna onboard + anna --open ``` 然后启动网关: @@ -150,7 +150,7 @@ services: docker compose up -d ``` -要运行初始设置,使用 `docker compose exec anna anna onboard` 或使用 `--admin-port 8080` 启动网关并通过 Web UI 进行配置。 +要运行初始设置,使用 `docker compose exec anna anna --open` 或使用 `--admin-port 8080` 启动网关并通过 Web UI 进行配置。 ### 本地构建 @@ -177,7 +177,7 @@ docker buildx build --platform linux/amd64,linux/arm64 -t anna . ## 环境变量 -配置通过管理面板管理(通过 `anna onboard` 或 `--admin-port`)。仅支持少量环境变量: +配置通过管理面板管理(通过 `anna --open` 或 `--admin-port`)。仅支持少量环境变量: | 变量 | 必需 | 描述 | | ------------------- | ---- | ----------------------------- | @@ -193,7 +193,7 @@ docker buildx build --platform linux/amd64,linux/arm64 -t anna . ```bash # 二进制文件 -anna gateway # 日志显示在终端中 +anna # 日志显示在终端中 # Docker docker logs anna diff --git a/docs/content/docs/index.ja.mdx b/docs/content/docs/index.ja.mdx index 54729e92..d6256bac 100644 --- a/docs/content/docs/index.ja.mdx +++ b/docs/content/docs/index.ja.mdx @@ -15,13 +15,13 @@ anna はロスレスなコンテキスト管理を備えたセルフホスト型 go install github.com/vaayne/anna@latest # インタラクティブセットアップ(Web UI を開く) -anna onboard +anna --open # チャットを開始 anna chat # またはデーモンを起動(ボット + スケジューラー) -anna gateway +anna ``` ## 探索 diff --git a/docs/content/docs/index.mdx b/docs/content/docs/index.mdx index aa5bd982..d6ffb83d 100644 --- a/docs/content/docs/index.mdx +++ b/docs/content/docs/index.mdx @@ -15,13 +15,13 @@ anna is a self-hosted AI assistant with lossless context management. Native Go b go install github.com/vaayne/anna@latest # Interactive setup (opens web UI) -anna onboard +anna --open # Start chatting anna chat # Or start the daemon (bots + scheduler) -anna gateway +anna ``` ## Explore diff --git a/docs/content/docs/index.zh.mdx b/docs/content/docs/index.zh.mdx index 4b8d66cc..769a6103 100644 --- a/docs/content/docs/index.zh.mdx +++ b/docs/content/docs/index.zh.mdx @@ -15,13 +15,13 @@ anna 是一个自托管的 AI 助手,具备无损上下文管理能力。原 go install github.com/vaayne/anna@latest # 交互式设置(打开 Web UI) -anna onboard +anna --open # 开始聊天 anna chat # 或启动守护进程(机器人 + 调度器) -anna gateway +anna ``` ## 探索 diff --git a/docs/src/routes/$lang/index.tsx b/docs/src/routes/$lang/index.tsx index 5bea343f..147cc3bd 100644 --- a/docs/src/routes/$lang/index.tsx +++ b/docs/src/routes/$lang/index.tsx @@ -6,9 +6,8 @@ import { t } from '@/lib/translations'; export const Route = createFileRoute('/$lang/')({ component: Home }); const terminalLines = [ - { prompt: true, text: 'anna onboard' }, - { prompt: false, text: 'config created at ~/.anna/config.yaml' }, - { prompt: false, text: 'opening setup at http://localhost:8080 ...' }, + { prompt: true, text: 'anna --open' }, + { prompt: false, text: 'Admin panel running at http://localhost:8787' }, { prompt: true, text: 'anna chat' }, { prompt: false, text: 'you: "summarize yesterday\'s conversation"' }, { prompt: false, text: 'anna: Yesterday you discussed migrating the' }, 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 diff --git a/internal/admin/CLAUDE.md b/internal/admin/CLAUDE.md index 632ec359..3193383c 100644 --- a/internal/admin/CLAUDE.md +++ b/internal/admin/CLAUDE.md @@ -119,7 +119,7 @@ Defined via CSS custom properties `[data-theme="terra"]` in `layout.templ`. Colo ```bash mise run templ:watch # Watch templ files, auto-regenerate + proxy mise run build # Build binary (runs generate first) -mise run onboard # Start admin panel at localhost:8080 +anna --open # Start admin panel at localhost:8080 ``` ### Rules diff --git a/internal/admin/agents.go b/internal/admin/agents.go index 1301fc47..48553029 100644 --- a/internal/admin/agents.go +++ b/internal/admin/agents.go @@ -1,52 +1,156 @@ package admin import ( + "context" + "fmt" "net/http" + "regexp" + "strconv" + "strings" + "github.com/vaayne/anna/internal/auth" "github.com/vaayne/anna/internal/config" ) +// slugify converts a name to a URL-safe agent ID. +// "My Cool Agent" -> "my-cool-agent" +var nonAlphaNum = regexp.MustCompile(`[^a-z0-9-]+`) + +func slugify(name string) string { + s := strings.ToLower(strings.TrimSpace(name)) + s = nonAlphaNum.ReplaceAllString(s, "-") + s = strings.Trim(s, "-") + if s == "" { + return "agent" + } + return s +} + 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) } func (s *Server) createAgent(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + info := UserFromContext(ctx) + var a config.Agent if err := decodeJSON(r, &a); err != nil { writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) return } - if a.ID == "" { - writeError(w, http.StatusBadRequest, "id is required") + if a.Name == "" { + writeError(w, http.StatusBadRequest, "name is required") return } - if a.Name == "" { - a.Name = a.ID + + // Auto-generate ID from name. + a.ID = slugify(a.Name) + + // Deduplicate: if the ID already exists, append a suffix. + if _, err := s.store.GetAgent(ctx, a.ID); err == nil { + for i := 2; ; i++ { + candidate := fmt.Sprintf("%s-%d", a.ID, i) + if _, err := s.store.GetAgent(ctx, candidate); err != nil { + a.ID = candidate + break + } + } } - if err := s.store.CreateAgent(r.Context(), a); err != nil { + + // Workspace is always the default path — never user-supplied. + a.Workspace = "" + + // Set creator. + if info != nil { + a.CreatorID = info.UserID + } + + // Non-admin users always get restricted scope, auto-assigned. + if info != nil && !info.IsAdmin { + a.Scope = config.AgentScopeRestricted + } else { + 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(ctx, a); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } + + // Auto-assign the creator if scope is restricted and user is non-admin. + if info != nil && !info.IsAdmin && a.Scope == config.AgentScopeRestricted { + if err := s.authStore.AssignAgent(ctx, info.UserID, a.ID); err != nil { + s.log.Error("auto-assign agent to creator", "user_id", info.UserID, "agent_id", a.ID, "error", err) + } + } + writeData(w, http.StatusCreated, a) } 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) } func (s *Server) updateAgent(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + info := UserFromContext(ctx) id := r.PathValue("id") + + // Check access: admin or creator. + existing, err := s.store.GetAgent(ctx, id) + if err != nil { + writeError(w, http.StatusNotFound, "agent not found") + return + } + if info != nil && !info.IsAdmin && existing.CreatorID != info.UserID { + writeError(w, http.StatusForbidden, "only the creator or an admin can edit this agent") + return + } + var a config.Agent if err := decodeJSON(r, &a); err != nil { writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) @@ -56,7 +160,21 @@ func (s *Server) updateAgent(w http.ResponseWriter, r *http.Request) { if a.Name == "" { a.Name = id } - if err := s.store.UpdateAgent(r.Context(), a); err != nil { + + // Non-admin: keep scope as-is, don't allow changing it. + if info != nil && !info.IsAdmin { + a.Scope = existing.Scope + } else { + 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(ctx, a); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } @@ -64,10 +182,165 @@ func (s *Server) updateAgent(w http.ResponseWriter, r *http.Request) { } func (s *Server) deleteAgent(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + info := UserFromContext(ctx) id := r.PathValue("id") - if err := s.store.DeleteAgent(r.Context(), id); err != nil { + + // Check access: admin or creator. + existing, err := s.store.GetAgent(ctx, id) + if err != nil { + writeError(w, http.StatusNotFound, "agent not found") + return + } + if info != nil && !info.IsAdmin && existing.CreatorID != info.UserID { + writeError(w, http.StatusForbidden, "only the creator or an admin can delete this agent") + return + } + + if err := s.store.DeleteAgent(ctx, id); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } 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: []string{info.Role}, + 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: []string{info.Role}, + 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..c9a5d468 --- /dev/null +++ b/internal/admin/agents_test.go @@ -0,0 +1,312 @@ +package admin_test + +import ( + "context" + "encoding/json" + "net/http" + "strconv" + "testing" + "time" + + "github.com/vaayne/anna/internal/auth" + "github.com/vaayne/anna/internal/config" +) + +// createTestAgent creates an agent via POST and returns its auto-generated ID. +func createTestAgent(t *testing.T, env *testEnv, a config.Agent) string { + t.Helper() + rr := doRequest(t, env, "POST", "/api/agents", a) + if rr.Code != http.StatusCreated { + t.Fatalf("create agent %q: status = %d (body: %s)", a.Name, rr.Code, rr.Body.String()) + } + resp := parseResponse(t, rr) + var created config.Agent + if err := json.Unmarshal(resp.Data, &created); err != nil { + t.Fatalf("unmarshal created agent: %v", err) + } + return created.ID +} + +func TestAgentScopeInCreateAndGet(t *testing.T) { + env := setupAdmin(t) + + agentID := createTestAgent(t, env, config.Agent{ + Name: "Restricted Agent", + Model: "anthropic/claude-sonnet-4-6", + Scope: "restricted", + Enabled: true, + }) + + // Verify scope is persisted. + rr := doRequest(t, env, "GET", "/api/agents/"+agentID, 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{ + Name: "Anna", + Model: "anthropic/claude-sonnet-4-6", + 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{ + Name: "Bad Scope", + Model: "anthropic/claude-sonnet-4-6", + 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() + + agentID := createTestAgent(t, env, config.Agent{ + Name: "Secure", + Model: "anthropic/claude-sonnet-4-6", + Scope: "restricted", + Enabled: true, + }) + + // 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/"+agentID+"/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/"+agentID+"/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/"+agentID+"/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/"+agentID+"/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/"+agentID+"/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) + + 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 (admin creates it). + agentID := createTestAgent(t, env, config.Agent{ + Name: "Private", + Model: "anthropic/claude-sonnet-4-6", + Scope: "restricted", + Enabled: true, + }) + + // Create non-admin user. + hash, _ := auth.HashPassword("userpassword") + user, _ := env.authStore.CreateUser(ctx, "regular", hash) + + 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 the restricted 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 == agentID { + 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 agent") + } + + // Assign user to the restricted agent. + _ = env.authStore.AssignAgent(ctx, user.ID, agentID) + + // 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 == agentID { + foundPrivate = true + } + } + if !foundPrivate { + t.Error("expected assigned user to see restricted agent") + } +} + +func TestNonAdminGetAgentAccessCheck(t *testing.T) { + env := setupAdmin(t) + ctx := context.Background() + + // Create a restricted agent. + agentID := createTestAgent(t, env, config.Agent{ + Name: "Secret", + Model: "anthropic/claude-sonnet-4-6", + Scope: "restricted", + Enabled: true, + }) + + // Create non-admin user. + hash, _ := auth.HashPassword("userpassword") + user, _ := env.authStore.CreateUser(ctx, "regular2", hash) + + 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/"+agentID, 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, agentID) + rr = doRequestWithSession(t, env.srv, sessionID, "GET", "/api/agents/"+agentID, 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/auth.go b/internal/admin/auth.go new file mode 100644 index 00000000..9a9aaf25 --- /dev/null +++ b/internal/admin/auth.go @@ -0,0 +1,229 @@ +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) { + // 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"` + } + 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.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 { + 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") { + s.rateLimiter.RecordIPAttempt(ip) + writeError(w, http.StatusConflict, "username already taken") + return + } + s.log.Error("create user", "error", err) + writeError(w, http.StatusInternalServerError, "internal error") + return + } + + // First user gets admin role. + if isFirstUser { + _ = s.authStore.UpdateUserRole(ctx, user.ID, auth.RoleAdmin) + } + + // 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.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 + } + + 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, + "role": info.Role, + "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/auth_test.go b/internal/admin/auth_test.go new file mode 100644 index 00000000..d2a9dec8 --- /dev/null +++ b/internal/admin/auth_test.go @@ -0,0 +1,219 @@ +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"` + Role string `json:"role"` + 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 role: should be "user", not "admin". + user, err := env.authStore.GetUserByUsername(context.Background(), "seconduser") + if err != nil { + t.Fatalf("GetUserByUsername: %v", err) + } + if user.Role == auth.RoleAdmin { + t.Error("second user should not have admin role") + } + if user.Role != auth.RoleUser { + t.Errorf("second user role = %q, want %q", user.Role, auth.RoleUser) + } +} + +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/auth_users.go b/internal/admin/auth_users.go new file mode 100644 index 00000000..91a98211 --- /dev/null +++ b/internal/admin/auth_users.go @@ -0,0 +1,277 @@ +package admin + +import ( + "fmt" + "net/http" + "strconv" + + "github.com/vaayne/anna/internal/auth" +) + +// --- Auth User Management API (admin-only) --- + +// authUserResponse is the response shape for auth user endpoints. +type authUserResponse struct { + ID int64 `json:"id"` + Username string `json:"username"` + Role string `json:"role"` + IsActive bool `json:"is_active"` + Identities []auth.Identity `json:"identities"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` +} + +func (s *Server) buildAuthUserResponse(r *http.Request, u auth.AuthUser) (authUserResponse, error) { + identities, err := s.authStore.ListIdentitiesByUser(r.Context(), u.ID) + if err != nil { + return authUserResponse{}, fmt.Errorf("list identities: %w", err) + } + + return authUserResponse{ + ID: u.ID, + Username: u.Username, + Role: u.Role, + IsActive: u.IsActive, + Identities: identities, + CreatedAt: u.CreatedAt.Format("2006-01-02 15:04:05"), + UpdatedAt: u.UpdatedAt.Format("2006-01-02 15:04:05"), + }, nil +} + +// listAuthUsers handles GET /api/auth/users. +func (s *Server) listAuthUsers(w http.ResponseWriter, r *http.Request) { + users, err := s.authStore.ListUsers(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list users: "+err.Error()) + return + } + + result := make([]authUserResponse, 0, len(users)) + for _, u := range users { + resp, err := s.buildAuthUserResponse(r, u) + if err != nil { + s.log.Error("build auth user response", "user_id", u.ID, "error", err) + continue + } + result = append(result, resp) + } + + writeData(w, http.StatusOK, result) +} + +// getAuthUser handles GET /api/auth/users/{id}. +func (s *Server) getAuthUser(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid user id") + return + } + + u, err := s.authStore.GetUser(r.Context(), id) + if err != nil { + writeError(w, http.StatusNotFound, "user not found") + return + } + + resp, err := s.buildAuthUserResponse(r, u) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to load user details: "+err.Error()) + return + } + + writeData(w, http.StatusOK, resp) +} + +// updateAuthUserRole handles PUT /api/auth/users/{id}/role. +func (s *Server) updateAuthUserRole(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid user id") + return + } + + var body struct { + Role string `json:"role"` + } + if err := decodeJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + + if body.Role != auth.RoleAdmin && body.Role != auth.RoleUser { + writeError(w, http.StatusBadRequest, "role must be 'admin' or 'user'") + return + } + + // Cannot demote yourself. + info := UserFromContext(r.Context()) + if info != nil && info.UserID == id && body.Role != auth.RoleAdmin { + writeError(w, http.StatusBadRequest, "cannot remove your own admin role") + return + } + + if err := s.authStore.UpdateUserRole(r.Context(), id, body.Role); err != nil { + writeError(w, http.StatusInternalServerError, "failed to update role: "+err.Error()) + return + } + + writeData(w, http.StatusOK, map[string]string{"status": "updated"}) +} + +// listAuthUserAgents handles GET /api/auth/users/{id}/agents. +func (s *Server) listAuthUserAgents(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid user id") + return + } + + agentIDs, err := s.authStore.ListUserAgentIDs(r.Context(), id) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list user agents: "+err.Error()) + return + } + + writeData(w, http.StatusOK, agentIDs) +} + +// updateAuthUserAgents handles PUT /api/auth/users/{id}/agents. +func (s *Server) updateAuthUserAgents(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid user id") + return + } + + var body struct { + AgentIDs []string `json:"agent_ids"` + } + if err := decodeJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + + // Verify user exists. + if _, err := s.authStore.GetUser(r.Context(), id); err != nil { + writeError(w, http.StatusNotFound, "user not found") + return + } + + ctx := r.Context() + + // Get current assignments. + currentIDs, err := s.authStore.ListUserAgentIDs(ctx, id) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to list current agents: "+err.Error()) + return + } + + currentSet := make(map[string]bool, len(currentIDs)) + for _, aid := range currentIDs { + currentSet[aid] = true + } + desiredSet := make(map[string]bool, len(body.AgentIDs)) + for _, aid := range body.AgentIDs { + desiredSet[aid] = true + } + + // Remove agents not in desired set. + for _, aid := range currentIDs { + if !desiredSet[aid] { + if err := s.authStore.RemoveAgent(ctx, id, aid); err != nil { + s.log.Error("remove agent assignment", "user_id", id, "agent_id", aid, "error", err) + } + } + } + + // Add agents not in current set. + for _, aid := range body.AgentIDs { + if !currentSet[aid] { + if err := s.authStore.AssignAgent(ctx, id, aid); err != nil { + s.log.Error("assign agent", "user_id", id, "agent_id", aid, "error", err) + } + } + } + + writeData(w, http.StatusOK, map[string]string{"status": "updated"}) +} + +// deleteAuthUserIdentity handles DELETE /api/auth/users/{id}/identities/{identityId}. +func (s *Server) deleteAuthUserIdentity(w http.ResponseWriter, r *http.Request) { + identityID, err := strconv.ParseInt(r.PathValue("identityId"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid identity id") + return + } + + ctx := r.Context() + + // Verify the identity exists. + identity, err := s.authStore.GetIdentity(ctx, identityID) + if err != nil { + writeError(w, http.StatusNotFound, "identity not found") + return + } + + // Verify it belongs to the specified user. + userID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid user id") + return + } + if identity.UserID != userID { + writeError(w, http.StatusBadRequest, "identity does not belong to this user") + return + } + + if err := s.authStore.DeleteIdentity(ctx, identityID); err != nil { + s.log.Error("admin delete identity", "id", identityID, "error", err) + writeError(w, http.StatusInternalServerError, "failed to delete identity") + return + } + + writeData(w, http.StatusOK, map[string]string{"status": "unlinked"}) +} + +// updateAuthUserActive handles PUT /api/auth/users/{id}/active. +func (s *Server) updateAuthUserActive(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid user id") + return + } + + var body struct { + IsActive bool `json:"is_active"` + } + if err := decodeJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + + // Cannot deactivate yourself. + info := UserFromContext(r.Context()) + if info != nil && info.UserID == id && !body.IsActive { + writeError(w, http.StatusBadRequest, "cannot deactivate your own account") + return + } + + u, err := s.authStore.GetUser(r.Context(), id) + if err != nil { + writeError(w, http.StatusNotFound, "user not found") + return + } + + u.IsActive = body.IsActive + if err := s.authStore.UpdateUser(r.Context(), u); err != nil { + writeError(w, http.StatusInternalServerError, "failed to update user: "+err.Error()) + return + } + + // If deactivating, delete all their sessions to force logout. + if !body.IsActive { + _ = s.authStore.DeleteUserSessions(r.Context(), id) + } + + writeData(w, http.StatusOK, map[string]string{"status": "updated"}) +} diff --git a/internal/admin/auth_users_test.go b/internal/admin/auth_users_test.go new file mode 100644 index 00000000..3df0fc4d --- /dev/null +++ b/internal/admin/auth_users_test.go @@ -0,0 +1,344 @@ +package admin_test + +import ( + "context" + "encoding/json" + "net/http" + "strconv" + "testing" + "time" + + "github.com/vaayne/anna/internal/auth" +) + +func TestListAuthUsers(t *testing.T) { + env := setupAdmin(t) + + rr := doRequest(t, env, "GET", "/api/auth/users", 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 users []struct { + ID int64 `json:"id"` + Username string `json:"username"` + IsActive bool `json:"is_active"` + Role string `json:"role"` + Identities []any `json:"identities"` + CreatedAt string `json:"created_at"` + } + if err := json.Unmarshal(resp.Data, &users); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(users) == 0 { + t.Fatal("expected at least one user") + } + // The admin user created in setupAdmin should be present. + found := false + for _, u := range users { + if u.Username == "testadmin" { + found = true + if !u.IsActive { + t.Error("expected admin user to be active") + } + if u.Role != "admin" { + t.Error("expected admin user to have admin role") + } + } + } + if !found { + t.Error("expected to find testadmin user") + } +} + +func TestGetAuthUser(t *testing.T) { + env := setupAdmin(t) + + rr := doRequest(t, env, "GET", "/api/auth/users/"+strconv.FormatInt(env.adminUser.ID, 10), 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 u struct { + ID int64 `json:"id"` + Username string `json:"username"` + Role string `json:"role"` + } + if err := json.Unmarshal(resp.Data, &u); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if u.ID != env.adminUser.ID { + t.Errorf("ID = %d, want %d", u.ID, env.adminUser.ID) + } + if u.Username != "testadmin" { + t.Errorf("Username = %q, want %q", u.Username, "testadmin") + } +} + +func TestGetAuthUserNotFound(t *testing.T) { + env := setupAdmin(t) + + rr := doRequest(t, env, "GET", "/api/auth/users/99999", nil) + if rr.Code != http.StatusNotFound { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusNotFound) + } +} + +func TestUpdateAuthUserRolePromote(t *testing.T) { + env := setupAdmin(t) + ctx := context.Background() + + hash, _ := auth.HashPassword("password1") + user, err := env.authStore.CreateUser(ctx, "regular1", hash) + if err != nil { + t.Fatalf("CreateUser: %v", err) + } + + body := map[string]string{"role": "admin"} + rr := doRequest(t, env, "PUT", "/api/auth/users/"+strconv.FormatInt(user.ID, 10)+"/role", body) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want %d (body: %s)", rr.Code, http.StatusOK, rr.Body.String()) + } + + got, _ := env.authStore.GetUser(ctx, user.ID) + if got.Role != auth.RoleAdmin { + t.Errorf("role = %q, want %q", got.Role, auth.RoleAdmin) + } +} + +func TestUpdateAuthUserRoleDemote(t *testing.T) { + env := setupAdmin(t) + ctx := context.Background() + + hash, _ := auth.HashPassword("password2") + user, _ := env.authStore.CreateUser(ctx, "admin2", hash) + _ = env.authStore.UpdateUserRole(ctx, user.ID, auth.RoleAdmin) + + body := map[string]string{"role": "user"} + rr := doRequest(t, env, "PUT", "/api/auth/users/"+strconv.FormatInt(user.ID, 10)+"/role", body) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want %d (body: %s)", rr.Code, http.StatusOK, rr.Body.String()) + } + + got, _ := env.authStore.GetUser(ctx, user.ID) + if got.Role != auth.RoleUser { + t.Errorf("role = %q, want %q", got.Role, auth.RoleUser) + } +} + +func TestCannotDemoteSelf(t *testing.T) { + env := setupAdmin(t) + + body := map[string]string{"role": "user"} + rr := doRequest(t, env, "PUT", "/api/auth/users/"+strconv.FormatInt(env.adminUser.ID, 10)+"/role", body) + if rr.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d (body: %s)", rr.Code, http.StatusBadRequest, rr.Body.String()) + } +} + +func TestUpdateAuthUserRoleInvalid(t *testing.T) { + env := setupAdmin(t) + + body := map[string]string{"role": "superadmin"} + rr := doRequest(t, env, "PUT", "/api/auth/users/"+strconv.FormatInt(env.adminUser.ID, 10)+"/role", body) + if rr.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusBadRequest) + } +} + +func TestListAndUpdateAuthUserAgents(t *testing.T) { + env := setupAdmin(t) + ctx := context.Background() + + // Create a user. + hash, _ := auth.HashPassword("password3") + user, _ := env.authStore.CreateUser(ctx, "agentuser", hash) + uid := strconv.FormatInt(user.ID, 10) + + // List agents - initially empty. + rr := doRequest(t, env, "GET", "/api/auth/users/"+uid+"/agents", nil) + if rr.Code != http.StatusOK { + t.Fatalf("list status = %d, want %d", rr.Code, http.StatusOK) + } + resp := parseResponse(t, rr) + var agentIDs []string + _ = json.Unmarshal(resp.Data, &agentIDs) + if len(agentIDs) != 0 { + t.Errorf("expected 0 agents, got %d", len(agentIDs)) + } + + // Assign agent. + body := map[string]any{"agent_ids": []string{"anna"}} + rr = doRequest(t, env, "PUT", "/api/auth/users/"+uid+"/agents", body) + if rr.Code != http.StatusOK { + t.Fatalf("update status = %d, want %d (body: %s)", rr.Code, http.StatusOK, rr.Body.String()) + } + + // Verify assignment. + rr = doRequest(t, env, "GET", "/api/auth/users/"+uid+"/agents", nil) + resp = parseResponse(t, rr) + _ = json.Unmarshal(resp.Data, &agentIDs) + if len(agentIDs) != 1 || agentIDs[0] != "anna" { + t.Errorf("expected [anna], got %v", agentIDs) + } + + // Remove by setting empty. + body = map[string]any{"agent_ids": []string{}} + rr = doRequest(t, env, "PUT", "/api/auth/users/"+uid+"/agents", body) + if rr.Code != http.StatusOK { + t.Fatalf("update status = %d, want %d", rr.Code, http.StatusOK) + } + + // Verify empty. + rr = doRequest(t, env, "GET", "/api/auth/users/"+uid+"/agents", nil) + resp = parseResponse(t, rr) + _ = json.Unmarshal(resp.Data, &agentIDs) + if len(agentIDs) != 0 { + t.Errorf("expected 0 agents after removal, got %d", len(agentIDs)) + } +} + +func TestUpdateAuthUserActive(t *testing.T) { + env := setupAdmin(t) + ctx := context.Background() + + // Create a user. + hash, _ := auth.HashPassword("password4") + user, _ := env.authStore.CreateUser(ctx, "deactivateme", hash) + uid := strconv.FormatInt(user.ID, 10) + + // Create session for the user. + sessionID := auth.NewSessionID() + _, _ = env.authStore.CreateSession(ctx, auth.Session{ + ID: sessionID, + UserID: user.ID, + ExpiresAt: time.Now().Add(auth.SessionDuration), + }) + + // Deactivate. + body := map[string]any{"is_active": false} + rr := doRequest(t, env, "PUT", "/api/auth/users/"+uid+"/active", body) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want %d (body: %s)", rr.Code, http.StatusOK, rr.Body.String()) + } + + // Verify inactive. + u, _ := env.authStore.GetUser(ctx, user.ID) + if u.IsActive { + t.Error("expected user to be inactive") + } + + // Verify user session was deleted (force logout). + _, err := env.authStore.GetSession(ctx, sessionID) + if err == nil { + t.Error("expected session to be deleted after deactivation") + } + + // Reactivate. + body = map[string]any{"is_active": true} + rr = doRequest(t, env, "PUT", "/api/auth/users/"+uid+"/active", body) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK) + } + + u, _ = env.authStore.GetUser(ctx, user.ID) + if !u.IsActive { + t.Error("expected user to be active after reactivation") + } +} + +func TestCannotDeactivateSelf(t *testing.T) { + env := setupAdmin(t) + + body := map[string]any{"is_active": false} + rr := doRequest(t, env, "PUT", "/api/auth/users/"+strconv.FormatInt(env.adminUser.ID, 10)+"/active", body) + if rr.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d (body: %s)", rr.Code, http.StatusBadRequest, rr.Body.String()) + } + + resp := parseResponse(t, rr) + if resp.Error != "cannot deactivate your own account" { + t.Errorf("error = %q, want %q", resp.Error, "cannot deactivate your own account") + } +} + +func TestNonAdminCannotAccessAuthUserAPIs(t *testing.T) { + env := setupAdmin(t) + ctx := context.Background() + + // Create non-admin user with session. + hash, _ := auth.HashPassword("password5") + user, _ := env.authStore.CreateUser(ctx, "nonadmin2", hash) + + sessionID := auth.NewSessionID() + _, _ = env.authStore.CreateSession(ctx, auth.Session{ + ID: sessionID, + UserID: user.ID, + ExpiresAt: time.Now().Add(auth.SessionDuration), + }) + + // All auth user endpoints should be 403. + endpoints := []struct { + method string + path string + }{ + {"GET", "/api/auth/users"}, + {"GET", "/api/auth/users/1"}, + {"PUT", "/api/auth/users/1/role"}, + {"GET", "/api/auth/users/1/agents"}, + {"PUT", "/api/auth/users/1/agents"}, + {"PUT", "/api/auth/users/1/active"}, + } + + for _, ep := range endpoints { + rr := doRequestWithSession(t, env.srv, sessionID, ep.method, ep.path, nil) + if rr.Code != http.StatusForbidden { + t.Errorf("%s %s: status = %d, want %d", ep.method, ep.path, rr.Code, http.StatusForbidden) + } + } +} + +func TestAuthUserWithLinkedIdentities(t *testing.T) { + env := setupAdmin(t) + ctx := context.Background() + + // Link an identity to the admin user. + _, err := env.authStore.CreateIdentity(ctx, auth.Identity{ + UserID: env.adminUser.ID, + Platform: "telegram", + ExternalID: "tg123", + Name: "Test TG User", + }) + if err != nil { + t.Fatalf("CreateIdentity: %v", err) + } + + // Get user details — should include the identity. + rr := doRequest(t, env, "GET", "/api/auth/users/"+strconv.FormatInt(env.adminUser.ID, 10), nil) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rr.Code, http.StatusOK) + } + + resp := parseResponse(t, rr) + var u struct { + Identities []struct { + Platform string `json:"platform"` + ExternalID string `json:"external_id"` + Name string `json:"name"` + } `json:"identities"` + } + if err := json.Unmarshal(resp.Data, &u); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(u.Identities) != 1 { + t.Fatalf("expected 1 identity, got %d", len(u.Identities)) + } + if u.Identities[0].Platform != "telegram" { + t.Errorf("platform = %q, want %q", u.Identities[0].Platform, "telegram") + } + if u.Identities[0].ExternalID != "tg123" { + t.Errorf("external_id = %q, want %q", u.Identities[0].ExternalID, "tg123") + } +} diff --git a/internal/admin/middleware.go b/internal/admin/middleware.go new file mode 100644 index 00000000..d24fc6c5 --- /dev/null +++ b/internal/admin/middleware.go @@ -0,0 +1,140 @@ +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"` + Role string `json:"role"` + 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 + } + + info := &AuthInfo{ + UserID: user.ID, + Username: user.Username, + Role: user.Role, + IsAdmin: user.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/profile.go b/internal/admin/profile.go new file mode 100644 index 00000000..3d584c2f --- /dev/null +++ b/internal/admin/profile.go @@ -0,0 +1,214 @@ +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"}) +} + +// listProfileMemories handles GET /api/auth/profile/memories. +func (s *Server) listProfileMemories(w http.ResponseWriter, r *http.Request) { + info := UserFromContext(r.Context()) + if info == nil { + writeError(w, http.StatusUnauthorized, "not authenticated") + return + } + + memories, err := s.store.ListUserMemories(r.Context(), info.UserID) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeData(w, http.StatusOK, memories) +} + +// setProfileMemory handles PUT /api/auth/profile/memories/{agentId}. +func (s *Server) setProfileMemory(w http.ResponseWriter, r *http.Request) { + info := UserFromContext(r.Context()) + if info == nil { + writeError(w, http.StatusUnauthorized, "not authenticated") + return + } + + agentID := r.PathValue("agentId") + var body struct { + Content string `json:"content"` + } + if err := decodeJSON(r, &body); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) + return + } + if err := s.store.SetUserAgentMemory(r.Context(), info.UserID, agentID, body.Content); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeData(w, http.StatusOK, map[string]string{"status": "saved"}) +} + +// deleteProfileMemory handles DELETE /api/auth/profile/memories/{agentId}. +func (s *Server) deleteProfileMemory(w http.ResponseWriter, r *http.Request) { + info := UserFromContext(r.Context()) + if info == nil { + writeError(w, http.StatusUnauthorized, "not authenticated") + return + } + + agentID := r.PathValue("agentId") + if err := s.store.DeleteUserAgentMemory(r.Context(), info.UserID, agentID); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeData(w, http.StatusOK, map[string]string{"status": "deleted"}) +} 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/admin/render.go b/internal/admin/render.go index 264a63bb..63411d25 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()) } @@ -36,11 +43,23 @@ 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. +// 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/scheduler.go b/internal/admin/scheduler.go index 82530ca9..734e172f 100644 --- a/internal/admin/scheduler.go +++ b/internal/admin/scheduler.go @@ -27,19 +27,29 @@ type schedulerJobJSON struct { } func (s *Server) listSchedulerJobs(w http.ResponseWriter, r *http.Request) { + info := UserFromContext(r.Context()) + rows, err := s.q.ListSchedulerJobs(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } + jobs := make([]schedulerJobJSON, 0, len(rows)) for _, row := range rows { - jobs = append(jobs, dbRowToJobJSON(row)) + j := dbRowToJobJSON(row) + // Non-admin users only see their own jobs. + if info != nil && !info.IsAdmin && j.UserID != info.UserID { + continue + } + jobs = append(jobs, j) } writeData(w, http.StatusOK, jobs) } func (s *Server) createSchedulerJob(w http.ResponseWriter, r *http.Request) { + info := UserFromContext(r.Context()) + var body schedulerJobJSON if err := decodeJSON(r, &body); err != nil { writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) @@ -57,6 +67,11 @@ func (s *Server) createSchedulerJob(w http.ResponseWriter, r *http.Request) { body.SessionMode = "reuse" } + // Non-admin users always own their jobs; only admins can create system jobs (user_id=0). + if info != nil && !info.IsAdmin { + body.UserID = info.UserID + } + id := generateShortID() enabled := int64(0) if body.Enabled { @@ -86,15 +101,23 @@ func (s *Server) createSchedulerJob(w http.ResponseWriter, r *http.Request) { } func (s *Server) updateSchedulerJob(w http.ResponseWriter, r *http.Request) { + info := UserFromContext(r.Context()) id := r.PathValue("id") - // Verify job exists. existing, err := s.q.GetSchedulerJob(r.Context(), id) if err != nil { writeError(w, http.StatusNotFound, "job not found") return } + // Non-admin users can only update their own jobs. + if info != nil && !info.IsAdmin { + if !existing.UserID.Valid || existing.UserID.Int64 != info.UserID { + writeError(w, http.StatusForbidden, "access denied") + return + } + } + var body schedulerJobJSON if err := decodeJSON(r, &body); err != nil { writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error()) @@ -117,6 +140,11 @@ func (s *Server) updateSchedulerJob(w http.ResponseWriter, r *http.Request) { body.SessionMode = existing.SessionMode } + // Non-admin users cannot change ownership. + if info != nil && !info.IsAdmin { + body.UserID = info.UserID + } + enabled := int64(0) if body.Enabled { enabled = 1 @@ -144,14 +172,23 @@ func (s *Server) updateSchedulerJob(w http.ResponseWriter, r *http.Request) { } func (s *Server) deleteSchedulerJob(w http.ResponseWriter, r *http.Request) { + info := UserFromContext(r.Context()) id := r.PathValue("id") - // Verify job exists. - if _, err := s.q.GetSchedulerJob(r.Context(), id); err != nil { + existing, err := s.q.GetSchedulerJob(r.Context(), id) + if err != nil { writeError(w, http.StatusNotFound, "job not found") return } + // Non-admin users can only delete their own jobs. + if info != nil && !info.IsAdmin { + if !existing.UserID.Valid || existing.UserID.Int64 != info.UserID { + writeError(w, http.StatusForbidden, "access denied") + return + } + } + if err := s.q.DeleteSchedulerJob(r.Context(), id); err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return diff --git a/internal/admin/server.go b/internal/admin/server.go index 97c557b6..36d08577 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,66 +15,122 @@ 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 + linkCodes *auth.LinkCodeStore + mem memory.Engine + db *sql.DB + 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, mem memory.Engine, db *sql.DB) *Server { +// The linkCodes store is shared with channel bots so codes generated in the +// admin panel can be consumed by channel handlers. +func New(store config.Store, authStore auth.AuthStore, engine *auth.PolicyEngine, mem memory.Engine, db *sql.DB, linkCodes *auth.LinkCodeStore) *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, - 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(), + linkCodes: linkCodes, + mem: mem, + db: db, + q: sqlc.New(db), + mux: http.NewServeMux(), + log: slog.With("component", "admin"), + corsOriginV: corsOrigin, } // 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) + + // 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) + s.mux.HandleFunc("GET /api/auth/profile/memories", s.listProfileMemories) + s.mux.HandleFunc("PUT /api/auth/profile/memories/{agentId}", s.setProfileMemory) + s.mux.HandleFunc("DELETE /api/auth/profile/memories/{agentId}", s.deleteProfileMemory) + // Page routes — templ-rendered HTML pages. - s.mux.HandleFunc("GET /providers", s.pageProviders) + // Admin-only pages: providers, channels, users, 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 /settings", s.adminOnlyMiddleware(http.HandlerFunc(s.pageSettings))) + s.mux.HandleFunc("GET /profile", s.pageProfile) - // Root redirects to /providers. - s.mux.HandleFunc("GET /{$}", s.redirectToProviders) + // Root redirect based on auth status. + s.mux.HandleFunc("GET /{$}", s.redirectRoot) - // 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) + // Admin-only API routes. + adminAPI := func(handler http.HandlerFunc) http.Handler { + return s.adminOnlyMiddleware(handler) + } + + // 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. + // Agent APIs (read/create for all authenticated users, update/delete for admin or creator). s.mux.HandleFunc("GET /api/agents", s.listAgents) s.mux.HandleFunc("POST /api/agents", 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) - // 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) - - // 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) + // 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)) + s.mux.Handle("PUT /api/channels/{platform}", adminAPI(s.updateChannel)) + + // User APIs (admin-only) — memory management and default agent. + s.mux.Handle("PUT /api/users/{id}/default-agent", adminAPI(s.updateUserDefaultAgent)) + 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)) + + // Auth user management APIs (admin-only). + s.mux.Handle("GET /api/auth/users", adminAPI(s.listAuthUsers)) + s.mux.Handle("GET /api/auth/users/{id}", adminAPI(s.getAuthUser)) + s.mux.Handle("PUT /api/auth/users/{id}/role", adminAPI(s.updateAuthUserRole)) + s.mux.Handle("GET /api/auth/users/{id}/agents", adminAPI(s.listAuthUserAgents)) + s.mux.Handle("PUT /api/auth/users/{id}/agents", adminAPI(s.updateAuthUserAgents)) + s.mux.Handle("DELETE /api/auth/users/{id}/identities/{identityId}", adminAPI(s.deleteAuthUserIdentity)) + s.mux.Handle("PUT /api/auth/users/{id}/active", adminAPI(s.updateAuthUserActive)) // Session APIs. s.mux.HandleFunc("GET /api/sessions", s.listSessions) @@ -80,9 +138,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,7 +148,7 @@ 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. + // Scheduler job APIs (all authenticated users; RBAC enforced in handlers). 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) @@ -99,34 +157,54 @@ func New(store config.Store, mem memory.Engine, db *sql.DB) *Server { 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) +} + +// 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 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 read from settings at startup. +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", "*") + 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") if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } - // JSON content-type for /api/ routes. + next.ServeHTTP(w, r) + }) +} + +// 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/server_test.go b/internal/admin/server_test.go index 91f7a7e2..5e1eadbe 100644 --- a/internal/admin/server_test.go +++ b/internal/admin/server_test.go @@ -9,14 +9,23 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/vaayne/anna/internal/admin" + "github.com/vaayne/anna/internal/auth" "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 +39,51 @@ func setupAdmin(t *testing.T) *admin.Server { t.Fatalf("SeedDefaults: %v", err) } + as := appdb.NewAuthStore(db) + if err := auth.SeedPolicies(context.Background(), as); err != nil { + t.Fatalf("SeedPolicies: %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, auth.NewLinkCodeStore()) + + // 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.UpdateUserRole(context.Background(), user.ID, auth.RoleAdmin) + + 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 +95,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 +123,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 +135,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 +144,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 +173,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,32 +194,41 @@ func TestListAgents(t *testing.T) { } func TestCreateAgent(t *testing.T) { - srv := setupAdmin(t) + env := setupAdmin(t) body := config.Agent{ - ID: "coder", Name: "Coder", Model: "anthropic/claude-sonnet-4-6", SystemPrompt: "You are a coding assistant.", - 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()) } + // Extract auto-generated ID from response. + resp := parseResponse(t, rr) + var created config.Agent + if err := json.Unmarshal(resp.Data, &created); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if created.ID == "" { + t.Fatal("expected non-empty auto-generated ID") + } + // Verify via get. - rr = doRequest(t, srv, "GET", "/api/agents/coder", nil) + rr = doRequest(t, env, "GET", "/api/agents/"+created.ID, 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 +236,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 +257,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 +277,104 @@ 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) + } + + 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) } } diff --git a/internal/admin/sessions.go b/internal/admin/sessions.go index cc53fd33..dcbc4fca 100644 --- a/internal/admin/sessions.go +++ b/internal/admin/sessions.go @@ -2,6 +2,7 @@ package admin import ( "encoding/json" + "fmt" "net/http" "time" @@ -48,14 +49,20 @@ func (s *Server) listSessions(w http.ResponseWriter, r *http.Request) { writeData(w, http.StatusOK, []any{}) return } + info := UserFromContext(r.Context()) sessions, err := s.mem.ListInfo(r.Context(), true) if err != nil { writeError(w, http.StatusInternalServerError, err.Error()) return } - resp := make([]sessionResponse, len(sessions)) - for i, info := range sessions { - resp[i] = toSessionResponse(info) + + resp := make([]sessionResponse, 0, len(sessions)) + for _, si := range sessions { + // Non-admin users only see their own sessions. + if info != nil && !info.IsAdmin && si.UserID != info.UserID { + continue + } + resp = append(resp, toSessionResponse(si)) } writeData(w, http.StatusOK, resp) } @@ -71,15 +78,23 @@ func (s *Server) getSession(w http.ResponseWriter, r *http.Request) { return } - info, err := s.mem.LoadInfo(r.Context(), sessionID) + authInfo := UserFromContext(r.Context()) + si, err := s.mem.LoadInfo(r.Context(), sessionID) if err != nil { writeError(w, http.StatusNotFound, err.Error()) return } + // Non-admin users can only view their own sessions. + if authInfo != nil && !authInfo.IsAdmin && si.UserID != authInfo.UserID { + writeError(w, http.StatusForbidden, "access denied") + return + } + resp := sessionDetailResponse{ - sessionResponse: toSessionResponse(info), + sessionResponse: toSessionResponse(si), } + info := si // Resolve agent name. if info.AgentID != "" { @@ -89,11 +104,11 @@ func (s *Server) getSession(w http.ResponseWriter, r *http.Request) { } } - // Resolve user name. + // Resolve user name from auth system. if info.UserID != 0 { - user, err := s.store.GetUser(r.Context(), info.UserID) + authUser, err := s.authStore.GetUser(r.Context(), info.UserID) if err == nil { - resp.UserName = user.Name + resp.UserName = authUser.Username } } @@ -107,6 +122,11 @@ func (s *Server) getSessionMessages(w http.ResponseWriter, r *http.Request) { return } + // Ownership check for non-admin users. + if err := s.checkSessionAccess(w, r, sessionID); err != nil { + return + } + // Load raw DB rows to preserve created_at timestamps. conv, err := s.q.GetConversationBySessionID(r.Context(), sessionID) if err != nil { @@ -122,6 +142,25 @@ func (s *Server) getSessionMessages(w http.ResponseWriter, r *http.Request) { writeData(w, http.StatusOK, serializeDBMessages(rows)) } +// checkSessionAccess verifies the current user has access to the session. +// Returns a non-nil error (and writes the HTTP response) if access is denied. +func (s *Server) checkSessionAccess(w http.ResponseWriter, r *http.Request, sessionID string) error { + info := UserFromContext(r.Context()) + if info == nil || info.IsAdmin || s.mem == nil { + return nil + } + si, err := s.mem.LoadInfo(r.Context(), sessionID) + if err != nil { + writeError(w, http.StatusNotFound, err.Error()) + return err + } + if si.UserID != info.UserID { + writeError(w, http.StatusForbidden, "access denied") + return fmt.Errorf("access denied") + } + return nil +} + func (s *Server) getSessionSystemPrompt(w http.ResponseWriter, r *http.Request) { sessionID := r.PathValue("sessionID") if sessionID == "" { @@ -133,6 +172,11 @@ func (s *Server) getSessionSystemPrompt(w http.ResponseWriter, r *http.Request) return } + // Ownership check for non-admin users. + if err := s.checkSessionAccess(w, r, sessionID); err != nil { + return + } + info, err := s.mem.LoadInfo(r.Context(), sessionID) if err != nil { writeError(w, http.StatusNotFound, err.Error()) diff --git a/internal/admin/ui/layout.templ b/internal/admin/ui/layout.templ index 49fb5a6b..fc94af9a 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) { @@ -54,8 +54,8 @@ templ Layout(activePage string, pageScript string, content templ.Component) { } - - @Navbar(activePage) + + @Navbar(activePage, username, isAdmin)

-
+
@content
-