From f32eefab14e12c09a3d670330af4c36ad0e443f5 Mon Sep 17 00:00:00 2001 From: Jeffrey Smith Date: Thu, 2 Apr 2026 18:20:16 +0000 Subject: [PATCH 1/2] Feat v0.7.7 API tokens + extension permissions (#61) Personal access tokens (PATs) for programmatic API access with SHA-256 hashing, permission scoping (git model), and Settings/Admin UI. Extension-declared user permissions with dynamic registry, gate_permission manifest field, permissions Starlark module, and grouped admin UI. Co-Authored-By: Claude Opus 4.6 (1M context) --- CHANGELOG.md | 36 +++ ROADMAP.md | 34 +-- VERSION | 2 +- ci/e2e-smoke-test.sh | 21 +- server/auth/hash.go | 13 + server/auth/permissions.go | 63 ++++- server/auth/permissions_test.go | 81 ++++++ .../migrations/postgres/015_api_tokens.sql | 20 ++ .../migrations/sqlite/015_api_tokens.sql | 18 ++ server/handlers/api_tokens.go | 260 +++++++++++++++++ server/handlers/api_tokens_test.go | 267 ++++++++++++++++++ server/handlers/auth.go | 60 +++- server/handlers/ext_api.go | 27 +- server/handlers/extension_permissions.go | 51 ++++ server/handlers/groups.go | 7 +- server/handlers/package_validate.go | 16 +- server/handlers/packages.go | 4 + server/handlers/profile_permissions.go | 5 +- server/main.go | 26 +- server/middleware/auth.go | 56 +++- server/middleware/permissions.go | 16 ++ server/models/models_api_token.go | 49 ++++ server/sandbox/permissions_module.go | 44 +++ server/sandbox/runner.go | 3 + server/store/api_token_iface.go | 34 +++ server/store/interfaces.go | 1 + server/store/postgres/api_tokens.go | 125 ++++++++ server/store/postgres/stores.go | 1 + server/store/sqlite/api_tokens.go | 172 +++++++++++ server/store/sqlite/stores.go | 1 + src/js/sw/surfaces/admin/groups.js | 43 ++- src/js/sw/surfaces/admin/users.js | 12 + src/js/sw/surfaces/settings/index.js | 3 + src/js/sw/surfaces/settings/tokens.js | 256 +++++++++++++++++ 34 files changed, 1769 insertions(+), 58 deletions(-) create mode 100644 server/auth/hash.go create mode 100644 server/auth/permissions_test.go create mode 100644 server/database/migrations/postgres/015_api_tokens.sql create mode 100644 server/database/migrations/sqlite/015_api_tokens.sql create mode 100644 server/handlers/api_tokens.go create mode 100644 server/handlers/api_tokens_test.go create mode 100644 server/models/models_api_token.go create mode 100644 server/sandbox/permissions_module.go create mode 100644 server/store/api_token_iface.go create mode 100644 server/store/postgres/api_tokens.go create mode 100644 server/store/sqlite/api_tokens.go create mode 100644 src/js/sw/surfaces/settings/tokens.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 8143bff..df45468 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,42 @@ All notable changes to Armature are documented here. +## v0.7.7 — API Tokens + Extension Permissions + +Personal access tokens (PATs) for programmatic API access, plus extension-declared +user permissions for backend RBAC enforcement. + +**API Tokens (PATs)** + +- Migration 015: `api_tokens` table (PG + SQLite) with SHA-256 hash, prefix, JSON permissions, expiry +- Token store interface + PG/SQLite implementations (Create, GetByHash, ListForUser, Revoke, CleanExpired, UpdateLastUsed) +- `POST /api/v1/auth/tokens` — create token (returns plaintext once), permissions validated as subset of user's +- `GET /api/v1/auth/tokens` — list my tokens; `DELETE /api/v1/auth/tokens/:id` — revoke +- `POST /api/v1/admin/tokens` — create token for any user (audit logged as `admin.token.create`) +- Auth middleware: `Bearer arm_pat_...` tokens validated alongside JWTs, user active check, fire-and-forget `last_used_at` update +- Permission scoping: PAT permissions used directly at request time (git model — retained until revoked) +- `auth.HashToken()` shared SHA-256 utility (replaces local `hashToken()` in auth.go) +- Settings UI: API Tokens tab with create form, permission checkboxes, copy-once display, revoke button +- Admin UI: "PAT" button on user rows creates tokens for any user +- `BootstrapPAT`: `ARMATURE_BOOTSTRAP_PAT=true` env var creates admin PAT at startup, writes to `/tmp/armature-admin-pat.txt` +- E2E smoke test: reads bootstrap PAT before falling back to login flow + +**Extension-Declared User Permissions** + +- Dynamic permission registry: `RegisterExtensionPermissions()` / `UnregisterExtensionPermissions()` with RWMutex +- `AllPermissionsWithExtensions()` returns kernel + extension permissions; `AllPermissionsGrouped()` for admin UI +- `user_permissions` manifest field: extensions declare user-facing permissions +- `gate_permission` manifest field: ext_api.go checks user permission before calling `on_request` +- `req["permissions"]` in Starlark request dict: user's effective permissions included for inline checks +- `permissions.check(user_id, perm)` Starlark module: read-only permission check, always available (no sandbox gate) +- Group UI: permissions grouped by source (Platform / package name) with section headings +- Boot-time scan: `RegisterAllExtensionUserPermissions()` populates registry from active packages +- Uninstall cleanup: `UnregisterExtensionPermissions()` called on package delete + +**Tests:** 10 new tests (7 handler + 3 auth registry) + +--- + ## v0.7.6 — Code Hygiene + Test Coverage **Critical Fixes** diff --git a/ROADMAP.md b/ROADMAP.md index 734508b..d937573 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,6 +1,6 @@ # Armature — Roadmap -## Current: v0.7.6 — Code Hygiene + Test Coverage +## Current: v0.7.7 — API Tokens + Extension Permissions Self-hosted extensible platform kernel. Auth, identity, packages, Starlark sandbox, storage, realtime, and ops are kernel primitives. Everything else @@ -246,27 +246,27 @@ sidecar auth (v0.10.x). | Step | Status | Description | |------|--------|-------------| -| `api_tokens` table + migration | | `id`, `user_id`, `name`, `token_hash`, `permissions` (JSON array), `expires_at`, `last_used_at`, `created_at`. PG + SQLite. | -| Token store interface | | `Create`, `GetByHash`, `ListForUser`, `Revoke`, `CleanExpired`, `UpdateLastUsed`. | -| Token generation endpoint | | `POST /api/v1/auth/tokens` — name, permissions (subset of user's), expires_at. Returns token once (plain text), stored as SHA-256 hash. | -| Token management endpoints | | `GET /api/v1/auth/tokens` (list mine), `DELETE /api/v1/auth/tokens/:id` (revoke). | -| Admin token endpoint | | `POST /api/v1/admin/tokens` — creates token for any user. Explicit admin action, audit logged. | -| Auth middleware update | | Accept `Bearer arm_pat_...` tokens alongside JWTs. Resolve user + scoped permissions from token. PAT requests skip refresh token flow. | -| Permission scoping | | Token permissions ⊆ creating user's resolved permissions at creation time. Validated on create. If user loses a permission later, token retains it until revoked (git model). | -| Settings UI | | Settings → API Tokens tab. Create, list, revoke. Show permissions, expiry, last used. Token value shown once on creation. | -| Admin UI | | Admin → People → user detail → "Create API Token" action. | -| E2E test migration | | Replace `ci/e2e-smoke-test.sh` login flow with PAT-based auth. Seed token in docker-compose bootstrap. | +| `api_tokens` table + migration | done | `id`, `user_id`, `name`, `token_hash`, `permissions` (JSON array), `expires_at`, `last_used_at`, `created_at`. PG + SQLite. | +| Token store interface | done | `Create`, `GetByHash`, `ListForUser`, `Revoke`, `CleanExpired`, `UpdateLastUsed`. | +| Token generation endpoint | done | `POST /api/v1/auth/tokens` — name, permissions (subset of user's), expires_at. Returns token once (plain text), stored as SHA-256 hash. | +| Token management endpoints | done | `GET /api/v1/auth/tokens` (list mine), `DELETE /api/v1/auth/tokens/:id` (revoke). | +| Admin token endpoint | done | `POST /api/v1/admin/tokens` — creates token for any user. Explicit admin action, audit logged. | +| Auth middleware update | done | Accept `Bearer arm_pat_...` tokens alongside JWTs. Resolve user + scoped permissions from token. PAT requests skip refresh token flow. | +| Permission scoping | done | Token permissions ⊆ creating user's resolved permissions at creation time. Validated on create. If user loses a permission later, token retains it until revoked (git model). | +| Settings UI | done | Settings → API Tokens tab. Create, list, revoke. Show permissions, expiry, last used. Token value shown once on creation. | +| Admin UI | done | Admin → People → user detail → "Create API Token" action. | +| E2E test migration | done | Replace `ci/e2e-smoke-test.sh` login flow with PAT-based auth. Seed token via `ARMATURE_BOOTSTRAP_PAT=true`. | **Extension-Declared User Permissions** | Step | Status | Description | |------|--------|-------------| -| `user_permissions` manifest field | | Extensions declare permissions users need: `"user_permissions": ["image-gen.use", "image-gen.admin"]`. | -| Dynamic permission registry | | On install: merge into valid permission set. On uninstall: remove. `AllPermissions` becomes `KernelPermissions + ExtensionPermissions`. | -| Group UI update | | Admin → Groups → permission checkboxes include extension-registered permissions, grouped by source package. | -| `permissions` Starlark module | | `permissions.check(user_id, "image-gen.use")` → resolves user's groups, returns bool. New permission: none required (read-only check against kernel data). | -| `gate_permission` manifest field | | Optional. If set, `ext_api.go` checks this permission before calling `on_request`. Extension doesn't execute for unauthorized users. | -| `req["permissions"]` in request dict | | `ext_api.go` resolves user permissions and includes them in the request dict. Extensions can check inline without the module. | +| `user_permissions` manifest field | done | Extensions declare permissions users need: `"user_permissions": ["image-gen.use", "image-gen.admin"]`. | +| Dynamic permission registry | done | On install: merge into valid permission set. On uninstall: remove. `AllPermissions` becomes `KernelPermissions + ExtensionPermissions`. | +| Group UI update | done | Admin → Groups → permission checkboxes include extension-registered permissions, grouped by source package. | +| `permissions` Starlark module | done | `permissions.check(user_id, "image-gen.use")` → resolves user's groups, returns bool. New permission: none required (read-only check against kernel data). | +| `gate_permission` manifest field | done | Optional. If set, `ext_api.go` checks this permission before calling `on_request`. Extension doesn't execute for unauthorized users. | +| `req["permissions"]` in request dict | done | `ext_api.go` resolves user permissions and includes them in the request dict. Extensions can check inline without the module. | ### v0.7.8 — Deferred Test Coverage diff --git a/VERSION b/VERSION index c006218..879be8a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.7.6 +0.7.7 diff --git a/ci/e2e-smoke-test.sh b/ci/e2e-smoke-test.sh index 88f1ca0..63113f6 100755 --- a/ci/e2e-smoke-test.sh +++ b/ci/e2e-smoke-test.sh @@ -32,11 +32,22 @@ echo " Server: ${SERVER_URL}" mkdir -p "${SCREENSHOT_DIR}" # ── Authenticate ───────────────────────────── -echo -e "${YELLOW}Authenticating...${NC}" -TOKEN=$(curl -sf -X POST "${SERVER_URL}/api/v1/auth/login" \ - -H "Content-Type: application/json" \ - -d "{\"login\":\"${ADMIN_USER}\",\"password\":\"${ADMIN_PASS}\"}" \ - | node -e "process.stdin.resume(); let d=''; process.stdin.on('data',c=>d+=c); process.stdin.on('end',()=>{try{console.log(JSON.parse(d).token)}catch(e){process.exit(1)}})") +# Try PAT first (from bootstrap), fall back to login +PAT_FILE="/tmp/armature-admin-pat.txt" +TOKEN="" + +if [ -f "$PAT_FILE" ]; then + TOKEN=$(cat "$PAT_FILE") + echo -e "${GREEN}Authenticated via bootstrap PAT${NC}" +fi + +if [ -z "$TOKEN" ]; then + echo -e "${YELLOW}Authenticating via login...${NC}" + TOKEN=$(curl -sf -X POST "${SERVER_URL}/api/v1/auth/login" \ + -H "Content-Type: application/json" \ + -d "{\"login\":\"${ADMIN_USER}\",\"password\":\"${ADMIN_PASS}\"}" \ + | node -e "process.stdin.resume(); let d=''; process.stdin.on('data',c=>d+=c); process.stdin.on('end',()=>{try{console.log(JSON.parse(d).token)}catch(e){process.exit(1)}})") +fi if [ -z "$TOKEN" ]; then echo -e "${RED}Failed to authenticate${NC}" diff --git a/server/auth/hash.go b/server/auth/hash.go new file mode 100644 index 0000000..f5b54de --- /dev/null +++ b/server/auth/hash.go @@ -0,0 +1,13 @@ +package auth + +import ( + "crypto/sha256" + "encoding/hex" +) + +// HashToken returns the hex-encoded SHA-256 hash of a token string. +// Used by both the token creation handler and the auth middleware. +func HashToken(token string) string { + h := sha256.Sum256([]byte(token)) + return hex.EncodeToString(h[:]) +} diff --git a/server/auth/permissions.go b/server/auth/permissions.go index fb9873a..9713cd2 100644 --- a/server/auth/permissions.go +++ b/server/auth/permissions.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "log" + "sync" "armature/database" "armature/store" @@ -30,9 +31,8 @@ const ( PermTokenUnlimited = "token.unlimited" // bypass token budgets ) -// AllPermissions is the complete set of valid permission strings. -// Used for validation in handlers and rendering checkboxes in admin UI. -var AllPermissions = []string{ +// KernelPermissions is the static set of platform permission strings. +var KernelPermissions = []string{ PermSurfaceAdminAccess, PermExtensionUse, PermExtensionInstall, @@ -42,6 +42,63 @@ var AllPermissions = []string{ PermTokenUnlimited, } +// AllPermissions is the kernel permissions. For the complete set including +// extension-declared permissions, use AllPermissionsWithExtensions(). +// Kept as a var for backward compatibility with EnsureAdminsGroup and +// other call sites that only need kernel permissions. +var AllPermissions = KernelPermissions + +// ── Extension Permission Registry ──────────── + +var ( + extPermsMu sync.RWMutex + extPerms = make(map[string][]string) // packageID → declared user permissions +) + +// RegisterExtensionPermissions registers user-facing permissions declared by +// an extension package. Called on install and at boot for active packages. +func RegisterExtensionPermissions(packageID string, perms []string) { + extPermsMu.Lock() + extPerms[packageID] = perms + extPermsMu.Unlock() +} + +// UnregisterExtensionPermissions removes user-facing permissions declared by +// an extension package. Called on uninstall. +func UnregisterExtensionPermissions(packageID string) { + extPermsMu.Lock() + delete(extPerms, packageID) + extPermsMu.Unlock() +} + +// AllPermissionsWithExtensions returns kernel + extension-declared permissions. +func AllPermissionsWithExtensions() []string { + extPermsMu.RLock() + defer extPermsMu.RUnlock() + + result := make([]string, len(KernelPermissions)) + copy(result, KernelPermissions) + for _, perms := range extPerms { + result = append(result, perms...) + } + return result +} + +// AllPermissionsGrouped returns permissions grouped by source. +// "kernel" key holds platform permissions; other keys are package IDs. +func AllPermissionsGrouped() map[string][]string { + extPermsMu.RLock() + defer extPermsMu.RUnlock() + + result := map[string][]string{ + "kernel": KernelPermissions, + } + for pkgID, perms := range extPerms { + result[pkgID] = perms + } + return result +} + // ── Resolution ────────────────────────────── // ResolvePermissions returns the effective permission set for a user. diff --git a/server/auth/permissions_test.go b/server/auth/permissions_test.go new file mode 100644 index 0000000..054e8c3 --- /dev/null +++ b/server/auth/permissions_test.go @@ -0,0 +1,81 @@ +package auth + +import ( + "testing" +) + +func TestRegisterExtensionPermissions(t *testing.T) { + // Clean state + extPermsMu.Lock() + extPerms = make(map[string][]string) + extPermsMu.Unlock() + + RegisterExtensionPermissions("image-gen", []string{"image-gen.use", "image-gen.admin"}) + + all := AllPermissionsWithExtensions() + found := 0 + for _, p := range all { + if p == "image-gen.use" || p == "image-gen.admin" { + found++ + } + } + if found != 2 { + t.Errorf("expected 2 extension permissions, found %d in %v", found, all) + } + + // Kernel permissions should also be present + for _, kp := range KernelPermissions { + foundKernel := false + for _, p := range all { + if p == kp { + foundKernel = true + break + } + } + if !foundKernel { + t.Errorf("kernel permission %q missing from AllPermissionsWithExtensions()", kp) + } + } +} + +func TestUnregisterExtensionPermissions(t *testing.T) { + extPermsMu.Lock() + extPerms = make(map[string][]string) + extPermsMu.Unlock() + + RegisterExtensionPermissions("image-gen", []string{"image-gen.use"}) + UnregisterExtensionPermissions("image-gen") + + all := AllPermissionsWithExtensions() + for _, p := range all { + if p == "image-gen.use" { + t.Error("unregistered permission should not appear") + } + } +} + +func TestAllPermissionsGrouped(t *testing.T) { + extPermsMu.Lock() + extPerms = make(map[string][]string) + extPermsMu.Unlock() + + RegisterExtensionPermissions("chat", []string{"chat.send", "chat.admin"}) + RegisterExtensionPermissions("notes", []string{"notes.edit"}) + + grouped := AllPermissionsGrouped() + + if len(grouped["kernel"]) != len(KernelPermissions) { + t.Errorf("expected %d kernel permissions, got %d", len(KernelPermissions), len(grouped["kernel"])) + } + if len(grouped["chat"]) != 2 { + t.Errorf("expected 2 chat permissions, got %d", len(grouped["chat"])) + } + if len(grouped["notes"]) != 1 { + t.Errorf("expected 1 notes permission, got %d", len(grouped["notes"])) + } + + // Clean up + extPermsMu.Lock() + extPerms = make(map[string][]string) + extPermsMu.Unlock() +} diff --git a/server/database/migrations/postgres/015_api_tokens.sql b/server/database/migrations/postgres/015_api_tokens.sql new file mode 100644 index 0000000..358ab81 --- /dev/null +++ b/server/database/migrations/postgres/015_api_tokens.sql @@ -0,0 +1,20 @@ +-- 015_api_tokens.sql — v0.7.7 +-- Personal access tokens for programmatic API access. + +CREATE TABLE IF NOT EXISTS api_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + prefix TEXT NOT NULL DEFAULT '', + permissions JSONB NOT NULL DEFAULT '[]', + expires_at TIMESTAMPTZ, + last_used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by UUID REFERENCES users(id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id); +CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash); +CREATE INDEX IF NOT EXISTS idx_api_tokens_expires ON api_tokens(expires_at) + WHERE expires_at IS NOT NULL; diff --git a/server/database/migrations/sqlite/015_api_tokens.sql b/server/database/migrations/sqlite/015_api_tokens.sql new file mode 100644 index 0000000..f7d113b --- /dev/null +++ b/server/database/migrations/sqlite/015_api_tokens.sql @@ -0,0 +1,18 @@ +-- 015_api_tokens.sql — v0.7.7 +-- Personal access tokens for programmatic API access. + +CREATE TABLE IF NOT EXISTS api_tokens ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + name TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + prefix TEXT NOT NULL DEFAULT '', + permissions TEXT NOT NULL DEFAULT '[]', + expires_at TEXT, + last_used_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + created_by TEXT REFERENCES users(id) ON DELETE SET NULL +); + +CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id); +CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash); diff --git a/server/handlers/api_tokens.go b/server/handlers/api_tokens.go new file mode 100644 index 0000000..dec8660 --- /dev/null +++ b/server/handlers/api_tokens.go @@ -0,0 +1,260 @@ +package handlers + +import ( + "crypto/rand" + "encoding/hex" + "net/http" + "time" + + "github.com/gin-gonic/gin" + + "armature/auth" + "armature/models" + "armature/store" +) + +// tokenPrefix is the prefix for all personal access tokens. +const tokenPrefix = "arm_pat_" + +// APITokenHandler manages personal access tokens. +type APITokenHandler struct { + stores store.Stores +} + +// NewAPITokenHandler creates a new API token handler. +func NewAPITokenHandler(s store.Stores) *APITokenHandler { + return &APITokenHandler{stores: s} +} + +// CreateToken creates a new personal access token for the current user. +// POST /api/v1/auth/tokens +func (h *APITokenHandler) CreateToken(c *gin.Context) { + var req models.APITokenCreateRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + + if req.Name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) + return + } + + userID := getUserID(c) + + // Parse optional expiry + var expiresAt *time.Time + if req.ExpiresAt != "" { + t, err := time.Parse(time.RFC3339, req.ExpiresAt) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "expires_at must be RFC3339 format"}) + return + } + if t.Before(time.Now()) { + c.JSON(http.StatusBadRequest, gin.H{"error": "expires_at must be in the future"}) + return + } + expiresAt = &t + } + + // Validate permissions: must be a subset of the creating user's resolved permissions + if err := h.validatePermissionSubset(c, userID, req.Permissions); err != nil { + return // error already sent + } + + // Generate token + rawToken, tokenHash, prefix := generateToken() + + token := &models.APIToken{ + UserID: userID, + Name: req.Name, + TokenHash: tokenHash, + Prefix: prefix, + Permissions: req.Permissions, + ExpiresAt: expiresAt, + CreatedBy: &userID, + } + + if err := h.stores.APITokens.Create(c.Request.Context(), token); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create token"}) + return + } + + // Audit log + AuditLog(h.stores.Audit, c, "token.create", "api_token", token.ID, map[string]interface{}{ + "name": req.Name, + "permissions": req.Permissions, + }) + + c.JSON(http.StatusCreated, models.APITokenCreateResponse{ + Token: rawToken, + ID: token.ID, + Name: token.Name, + Prefix: token.Prefix, + Permissions: token.Permissions, + ExpiresAt: token.ExpiresAt, + CreatedAt: token.CreatedAt, + }) +} + +// ListTokens lists all tokens for the current user. +// GET /api/v1/auth/tokens +func (h *APITokenHandler) ListTokens(c *gin.Context) { + userID := getUserID(c) + tokens, err := h.stores.APITokens.ListForUser(c.Request.Context(), userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list tokens"}) + return + } + c.JSON(http.StatusOK, gin.H{"data": tokens}) +} + +// RevokeToken revokes a token owned by the current user. +// DELETE /api/v1/auth/tokens/:id +func (h *APITokenHandler) RevokeToken(c *gin.Context) { + userID := getUserID(c) + id := c.Param("id") + + n, err := h.stores.APITokens.Revoke(c.Request.Context(), id, userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to revoke token"}) + return + } + if n == 0 { + c.JSON(http.StatusNotFound, gin.H{"error": "token not found"}) + return + } + + AuditLog(h.stores.Audit, c, "token.revoke", "api_token", id, nil) + + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +// AdminCreateToken creates a token for any user (admin only). +// POST /api/v1/admin/tokens +func (h *APITokenHandler) AdminCreateToken(c *gin.Context) { + var req models.AdminTokenCreateRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"}) + return + } + + if req.UserID == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "user_id is required"}) + return + } + if req.Name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"}) + return + } + + // Verify target user exists + targetUser, err := h.stores.Users.GetByID(c.Request.Context(), req.UserID) + if err != nil || targetUser == nil { + c.JSON(http.StatusNotFound, gin.H{"error": "target user not found"}) + return + } + + // Parse optional expiry + var expiresAt *time.Time + if req.ExpiresAt != "" { + t, err := time.Parse(time.RFC3339, req.ExpiresAt) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "expires_at must be RFC3339 format"}) + return + } + if t.Before(time.Now()) { + c.JSON(http.StatusBadRequest, gin.H{"error": "expires_at must be in the future"}) + return + } + expiresAt = &t + } + + // Validate permissions: must be subset of TARGET user's permissions + if err := h.validatePermissionSubset(c, req.UserID, req.Permissions); err != nil { + return + } + + rawToken, tokenHash, prefix := generateToken() + adminID := getUserID(c) + + token := &models.APIToken{ + UserID: req.UserID, + Name: req.Name, + TokenHash: tokenHash, + Prefix: prefix, + Permissions: req.Permissions, + ExpiresAt: expiresAt, + CreatedBy: &adminID, + } + + if err := h.stores.APITokens.Create(c.Request.Context(), token); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create token"}) + return + } + + AuditLog(h.stores.Audit, c, "admin.token.create", "api_token", token.ID, map[string]interface{}{ + "target_user": req.UserID, + "name": req.Name, + "permissions": req.Permissions, + }) + + c.JSON(http.StatusCreated, models.APITokenCreateResponse{ + Token: rawToken, + ID: token.ID, + Name: token.Name, + Prefix: token.Prefix, + Permissions: token.Permissions, + ExpiresAt: token.ExpiresAt, + CreatedAt: token.CreatedAt, + }) +} + +// ── Helpers ────────────────────────────────── + +// generateToken creates a raw token string, its SHA-256 hash, and the prefix. +// Format: arm_pat_ + 32 random bytes hex-encoded (72 chars total). +func generateToken() (raw, hash, prefix string) { + b := make([]byte, 32) + if _, err := rand.Read(b); err != nil { + panic("crypto/rand failed: " + err.Error()) + } + hexPart := hex.EncodeToString(b) + raw = tokenPrefix + hexPart + hash = auth.HashToken(raw) + prefix = hexPart[:8] + return +} + +// validatePermissionSubset checks that the requested permissions are a subset +// of the specified user's resolved permissions. Sends an error response and +// returns a non-nil error if validation fails. +func (h *APITokenHandler) validatePermissionSubset(c *gin.Context, userID string, requested []string) error { + if len(requested) == 0 { + return nil + } + + userPerms, err := auth.ResolvePermissions(c.Request.Context(), h.stores, userID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to resolve permissions"}) + return err + } + + for _, p := range requested { + if !userPerms[p] { + c.JSON(http.StatusBadRequest, gin.H{ + "error": "permission not available: " + p, + }) + return errPermissionNotAvailable + } + } + return nil +} + +var errPermissionNotAvailable = &apiError{message: "permission not available"} + +type apiError struct { + message string +} + +func (e *apiError) Error() string { return e.message } diff --git a/server/handlers/api_tokens_test.go b/server/handlers/api_tokens_test.go new file mode 100644 index 0000000..49581cc --- /dev/null +++ b/server/handlers/api_tokens_test.go @@ -0,0 +1,267 @@ +package handlers + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/gin-gonic/gin" + + "armature/auth" + "armature/models" + "armature/store" +) + +// ── Token Generation Tests ────────────────── + +func TestGenerateToken_Format(t *testing.T) { + raw, hash, prefix := generateToken() + + if !strings.HasPrefix(raw, "arm_pat_") { + t.Errorf("expected arm_pat_ prefix, got %q", raw[:8]) + } + + // arm_pat_ (8) + 64 hex chars = 72 + if len(raw) != 72 { + t.Errorf("expected 72 chars, got %d", len(raw)) + } + + if len(prefix) != 8 { + t.Errorf("expected 8-char prefix, got %d", len(prefix)) + } + + // prefix should match the first 8 hex chars after arm_pat_ + hexPart := raw[len("arm_pat_"):] + if prefix != hexPart[:8] { + t.Errorf("prefix %q does not match hex start %q", prefix, hexPart[:8]) + } + + // Hash should be consistent + rehash := auth.HashToken(raw) + if hash != rehash { + t.Errorf("hash mismatch: generate=%q, rehash=%q", hash, rehash) + } +} + +func TestGenerateToken_Unique(t *testing.T) { + raw1, _, _ := generateToken() + raw2, _, _ := generateToken() + if raw1 == raw2 { + t.Error("two generated tokens should not be identical") + } +} + +// ── Handler Tests ─────────────────────────── + +func TestCreateToken_MissingName(t *testing.T) { + gin.SetMode(gin.TestMode) + h := &APITokenHandler{stores: testStoresForTokens()} + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Set("user_id", "user-1") + c.Request = httptest.NewRequest("POST", "/auth/tokens", + strings.NewReader(`{"permissions":[]}`)) + c.Request.Header.Set("Content-Type", "application/json") + + h.CreateToken(c) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } +} + +func TestCreateToken_InvalidExpiry(t *testing.T) { + gin.SetMode(gin.TestMode) + h := &APITokenHandler{stores: testStoresForTokens()} + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Set("user_id", "user-1") + c.Request = httptest.NewRequest("POST", "/auth/tokens", + strings.NewReader(`{"name":"test","permissions":[],"expires_at":"2020-01-01T00:00:00Z"}`)) + c.Request.Header.Set("Content-Type", "application/json") + + h.CreateToken(c) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } + var resp map[string]string + json.Unmarshal(w.Body.Bytes(), &resp) + if !strings.Contains(resp["error"], "future") { + t.Errorf("expected future error, got %q", resp["error"]) + } +} + +func TestCreateToken_PermissionSubsetValidation(t *testing.T) { + gin.SetMode(gin.TestMode) + + // User only has extension.use — requesting admin access should fail + stores := testStoresForTokens() + h := &APITokenHandler{stores: stores} + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Set("user_id", "user-1") + c.Request = httptest.NewRequest("POST", "/auth/tokens", + strings.NewReader(`{"name":"test","permissions":["surface.admin.access"]}`)) + c.Request.Header.Set("Content-Type", "application/json") + + h.CreateToken(c) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } +} + +func TestCreateToken_Success(t *testing.T) { + gin.SetMode(gin.TestMode) + + stores := testStoresForTokens() + h := &APITokenHandler{stores: stores} + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Set("user_id", "user-1") + c.Request = httptest.NewRequest("POST", "/auth/tokens", + strings.NewReader(`{"name":"my-token","permissions":["extension.use"]}`)) + c.Request.Header.Set("Content-Type", "application/json") + + h.CreateToken(c) + + if w.Code != http.StatusCreated { + t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String()) + } + + var resp models.APITokenCreateResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + + if !strings.HasPrefix(resp.Token, "arm_pat_") { + t.Errorf("expected arm_pat_ prefix in token") + } + if resp.Name != "my-token" { + t.Errorf("expected name 'my-token', got %q", resp.Name) + } + if len(resp.Prefix) != 8 { + t.Errorf("expected 8-char prefix, got %q", resp.Prefix) + } +} + +func TestAdminCreateToken_MissingUserID(t *testing.T) { + gin.SetMode(gin.TestMode) + h := &APITokenHandler{stores: testStoresForTokens()} + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Set("user_id", "admin-1") + c.Request = httptest.NewRequest("POST", "/admin/tokens", + strings.NewReader(`{"name":"test","permissions":[]}`)) + c.Request.Header.Set("Content-Type", "application/json") + + h.AdminCreateToken(c) + + if w.Code != http.StatusBadRequest { + t.Errorf("expected 400, got %d", w.Code) + } +} + +// ── Test Stores ───────────────────────────── + +// testStoresForTokens creates a minimal Stores with an in-memory token store +// and a mock group store that gives user-1 the extension.use permission. +func testStoresForTokens() store.Stores { + return store.Stores{ + APITokens: &mockAPITokenStore{tokens: make(map[string]*models.APIToken)}, + Groups: &mockGroupStoreForTokens{}, + Users: &mockUserStoreForTokens{}, + } +} + +// ── Mock API Token Store ──────────────────── + +type mockAPITokenStore struct { + tokens map[string]*models.APIToken +} + +func (m *mockAPITokenStore) Create(_ context.Context, token *models.APIToken) error { + if token.ID == "" { + token.ID = "tok-" + token.Prefix + } + token.CreatedAt = time.Now() + m.tokens[token.ID] = token + return nil +} + +func (m *mockAPITokenStore) GetByHash(_ context.Context, hash string) (*models.APIToken, error) { + for _, t := range m.tokens { + if t.TokenHash == hash { + return t, nil + } + } + return nil, nil +} + +func (m *mockAPITokenStore) ListForUser(_ context.Context, userID string) ([]models.APIToken, error) { + var result []models.APIToken + for _, t := range m.tokens { + if t.UserID == userID { + result = append(result, *t) + } + } + return result, nil +} + +func (m *mockAPITokenStore) Revoke(_ context.Context, id, userID string) (int64, error) { + if t, ok := m.tokens[id]; ok && t.UserID == userID { + delete(m.tokens, id) + return 1, nil + } + return 0, nil +} + +func (m *mockAPITokenStore) RevokeByID(_ context.Context, id string) (int64, error) { + if _, ok := m.tokens[id]; ok { + delete(m.tokens, id) + return 1, nil + } + return 0, nil +} + +func (m *mockAPITokenStore) CleanExpired(_ context.Context) (int64, error) { return 0, nil } +func (m *mockAPITokenStore) UpdateLastUsed(_ context.Context, _ string) error { return nil } + +// ── Mock Group Store ──────────────────────── + +type mockGroupStoreForTokens struct { + store.GroupStore // embed interface — only override what we need +} + +func (m *mockGroupStoreForTokens) ListForUser(_ context.Context, userID string) ([]models.Group, error) { + if userID == "user-1" { + return []models.Group{ + {BaseModel: models.BaseModel{ID: auth.EveryoneGroupID}, Permissions: []string{"extension.use"}}, + }, nil + } + return []models.Group{}, nil +} + +func (m *mockGroupStoreForTokens) GetUserGroupIDs(_ context.Context, _ string) ([]string, error) { + return []string{auth.EveryoneGroupID}, nil +} + +// ── Mock User Store ───────────────────────── + +type mockUserStoreForTokens struct { + store.UserStore // embed interface +} + +func (m *mockUserStoreForTokens) GetByID(_ context.Context, id string) (*models.User, error) { + return &models.User{BaseModel: models.BaseModel{ID: id}, Username: "test", IsActive: true}, nil +} diff --git a/server/handlers/auth.go b/server/handlers/auth.go index 99e1664..eebd495 100644 --- a/server/handlers/auth.go +++ b/server/handlers/auth.go @@ -2,13 +2,12 @@ package handlers import ( "context" - "crypto/sha256" "encoding/base64" - "encoding/hex" "encoding/json" "fmt" "log" "net/http" + "os" "strings" "time" @@ -384,8 +383,7 @@ func (h *AuthHandler) generateTokens(user *models.User, keepLogin bool) (gin.H, } func hashToken(token string) string { - h := sha256.Sum256([]byte(token)) - return hex.EncodeToString(h[:]) + return auth.HashToken(token) } // ── Vault Lifecycle ───────────────────────── @@ -582,6 +580,60 @@ func BootstrapAdmin(cfg *config.Config, s store.Stores, uekCache ...*crypto.UEKC log.Printf(" ✅ Admin user '%s' created", cfg.AdminUsername) } +// BootstrapPAT creates a personal access token for the admin user when +// ARMATURE_BOOTSTRAP_PAT=true. Writes the token to /tmp/armature-admin-pat.txt +// for use by CI scripts. The token has all permissions and no expiry. +func BootstrapPAT(cfg *config.Config, s store.Stores) { + if os.Getenv("ARMATURE_BOOTSTRAP_PAT") != "true" { + return + } + if cfg.AdminUsername == "" || s.APITokens == nil { + return + } + + ctx := context.Background() + admin, err := s.Users.GetByUsername(ctx, cfg.AdminUsername) + if err != nil || admin == nil { + log.Printf("⚠ BootstrapPAT: admin user not found") + return + } + + // Check if a bootstrap token already exists + existing, _ := s.APITokens.ListForUser(ctx, admin.ID) + for _, t := range existing { + if t.Name == "bootstrap-ci" { + log.Printf(" ℹ Bootstrap PAT already exists (prefix: %s)", t.Prefix) + return + } + } + + // Generate and store + raw, tokenHash, prefix := generateToken() + allPerms := auth.AllPermissions + permsCopy := make([]string, len(allPerms)) + copy(permsCopy, allPerms) + + token := &models.APIToken{ + UserID: admin.ID, + Name: "bootstrap-ci", + TokenHash: tokenHash, + Prefix: prefix, + Permissions: permsCopy, + CreatedBy: &admin.ID, + } + if err := s.APITokens.Create(ctx, token); err != nil { + log.Printf("⚠ BootstrapPAT: failed to create token: %v", err) + return + } + + // Write token to file for CI consumption + if err := os.WriteFile("/tmp/armature-admin-pat.txt", []byte(raw), 0600); err != nil { + log.Printf("⚠ BootstrapPAT: failed to write token file: %v", err) + // Still log it for docker-compose stdout capture + } + log.Printf(" ✅ Bootstrap PAT created (prefix: %s), written to /tmp/armature-admin-pat.txt", prefix) +} + // SeedUsers creates or updates users from the SEED_USERS env var. // Format: "user:pass[:admin],user2:pass2" — third field "admin" adds to Admins group. // Upsert: existing users get their password refreshed on every restart. diff --git a/server/handlers/ext_api.go b/server/handlers/ext_api.go index af68353..8ae6522 100644 --- a/server/handlers/ext_api.go +++ b/server/handlers/ext_api.go @@ -41,6 +41,7 @@ import ( "github.com/gin-gonic/gin" "go.starlark.net/starlark" + "armature/auth" "armature/models" "armature/sandbox" "armature/store" @@ -125,8 +126,17 @@ func (h *ExtAPIHandler) Handle(c *gin.Context) { return } + // ── 4b. Gate permission check ────────────── + if gatePerm, _ := pkg.Manifest["gate_permission"].(string); gatePerm != "" { + userPerms, err := auth.ResolvePermissions(c.Request.Context(), h.stores, getUserID(c)) + if err != nil || !userPerms[gatePerm] { + c.JSON(http.StatusForbidden, gin.H{"error": "permission required: " + gatePerm}) + return + } + } + // ── 5. Build request dict ────────────────── - reqDict, err := buildRequestDict(c, rawPath) + reqDict, err := buildRequestDict(c, rawPath, h.stores) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read request: " + err.Error()}) return @@ -228,7 +238,7 @@ func matchAPIRoute(manifest map[string]any, method, path string) bool { // "body": "...", // "user_id": "uuid", // } -func buildRequestDict(c *gin.Context, path string) (*starlark.Dict, error) { +func buildRequestDict(c *gin.Context, path string, stores ...store.Stores) (*starlark.Dict, error) { // Read body (capped at 1 MB for safety) body, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20)) if err != nil { @@ -251,13 +261,24 @@ func buildRequestDict(c *gin.Context, path string) (*starlark.Dict, error) { } } - d := starlark.NewDict(6) + d := starlark.NewDict(7) _ = d.SetKey(starlark.String("method"), starlark.String(c.Request.Method)) _ = d.SetKey(starlark.String("path"), starlark.String(path)) _ = d.SetKey(starlark.String("headers"), hdrs) _ = d.SetKey(starlark.String("query"), query) _ = d.SetKey(starlark.String("body"), starlark.String(string(body))) _ = d.SetKey(starlark.String("user_id"), starlark.String(getUserID(c))) + + // Resolve user permissions for extension inline checks + if len(stores) > 0 { + userPerms, _ := auth.ResolvePermissions(c.Request.Context(), stores[0], getUserID(c)) + permList := make([]starlark.Value, 0, len(userPerms)) + for p := range userPerms { + permList = append(permList, starlark.String(p)) + } + _ = d.SetKey(starlark.String("permissions"), starlark.NewList(permList)) + } + return d, nil } diff --git a/server/handlers/extension_permissions.go b/server/handlers/extension_permissions.go index bacde38..98882e5 100644 --- a/server/handlers/extension_permissions.go +++ b/server/handlers/extension_permissions.go @@ -1,10 +1,13 @@ package handlers import ( + "context" + "log" "net/http" "github.com/gin-gonic/gin" + "armature/auth" "armature/models" "armature/store" ) @@ -167,6 +170,28 @@ func (h *ExtPermHandler) maybeSuspend(c *gin.Context, pkgID string) { } } +// RegisterAllExtensionUserPermissions scans all active packages and registers +// their user_permissions in the dynamic permission registry. Called at boot. +func RegisterAllExtensionUserPermissions(stores store.Stores) { + ctx := context.Background() + pkgs, err := stores.Packages.List(ctx) + if err != nil { + return + } + count := 0 + for _, pkg := range pkgs { + if pkg.Manifest != nil { + SyncUserPermissions(pkg.Manifest, pkg.ID) + if _, ok := pkg.Manifest["user_permissions"]; ok { + count++ + } + } + } + if count > 0 { + log.Printf(" ✅ Registered user permissions from %d package(s)", count) + } +} + // ── Manifest Permission Parsing ────────────── // SyncManifestPermissions extracts the "permissions" array from a @@ -208,5 +233,31 @@ func SyncManifestPermissions(c *gin.Context, stores store.Stores, pkgID string, // Package needs review before activation _ = stores.Packages.SetStatus(c.Request.Context(), pkgID, models.PackageStatusPendingReview) + // Register user-facing permissions in the dynamic permission registry + SyncUserPermissions(manifest, pkgID) + return perms } + +// SyncUserPermissions registers extension-declared user permissions in the +// kernel's dynamic permission registry. Called on install and at boot. +func SyncUserPermissions(manifest map[string]any, pkgID string) { + raw, ok := manifest["user_permissions"] + if !ok { + return + } + arr, ok := raw.([]any) + if !ok { + return + } + + var userPerms []string + for _, v := range arr { + if s, ok := v.(string); ok && s != "" { + userPerms = append(userPerms, s) + } + } + if len(userPerms) > 0 { + auth.RegisterExtensionPermissions(pkgID, userPerms) + } +} diff --git a/server/handlers/groups.go b/server/handlers/groups.go index 9bbc09b..7205741 100644 --- a/server/handlers/groups.go +++ b/server/handlers/groups.go @@ -165,7 +165,7 @@ func (h *GroupHandler) UpdateGroup(c *gin.Context) { // Validate permissions if provided if patch.Permissions != nil { - valid := auth.AllPermissions + valid := auth.AllPermissionsWithExtensions() validSet := make(map[string]bool, len(valid)) for _, p := range valid { validSet[p] = true @@ -433,7 +433,10 @@ func (h *GroupHandler) DeleteResourceGrant(c *gin.Context) { // ListPermissions returns all valid permission strings. // GET /api/v1/admin/permissions func (h *GroupHandler) ListPermissions(c *gin.Context) { - c.JSON(http.StatusOK, gin.H{"permissions": auth.AllPermissions}) + c.JSON(http.StatusOK, gin.H{ + "permissions": auth.AllPermissionsWithExtensions(), + "grouped": auth.AllPermissionsGrouped(), + }) } // GetUserPermissions returns the effective permissions for a given user. diff --git a/server/handlers/package_validate.go b/server/handlers/package_validate.go index 0036eaa..2a29ca9 100644 --- a/server/handlers/package_validate.go +++ b/server/handlers/package_validate.go @@ -27,8 +27,10 @@ type ManifestInfo struct { HasSettings bool HasExports bool Dependencies map[string]any - Requires []string - Signature string // reserved for future package signing + Requires []string + Signature string // reserved for future package signing + UserPermissions []string // user-facing permissions declared by the extension + GatePermission string // if set, checks this user permission before calling on_request } // ValidateManifest parses a manifest map and validates all required fields, @@ -97,6 +99,16 @@ func ValidateManifest(manifest map[string]any) (*ManifestInfo, error) { } } + // Extension-declared user permissions + if ups, ok := manifest["user_permissions"].([]any); ok { + for _, v := range ups { + if s, ok := v.(string); ok && s != "" { + info.UserPermissions = append(info.UserPermissions, s) + } + } + } + info.GatePermission, _ = manifest["gate_permission"].(string) + info.SchemaVersion = ParseSchemaVersion(manifest) // ── Type-specific constraints ──────────────────────────────── diff --git a/server/handlers/packages.go b/server/handlers/packages.go index f5b8a8e..c787193 100644 --- a/server/handlers/packages.go +++ b/server/handlers/packages.go @@ -16,6 +16,7 @@ import ( "github.com/gin-gonic/gin" "go.starlark.net/starlark" + "armature/auth" "armature/database" "armature/events" "armature/models" @@ -193,6 +194,9 @@ func (h *PackageHandler) DeletePackage(c *gin.Context) { return } + // Unregister user-facing permissions from the dynamic registry + auth.UnregisterExtensionPermissions(id) + // Clean up extracted static assets if h.packagesDir != "" { assetDir := filepath.Join(h.packagesDir, id) diff --git a/server/handlers/profile_permissions.go b/server/handlers/profile_permissions.go index 32687e3..04461aa 100644 --- a/server/handlers/profile_permissions.go +++ b/server/handlers/profile_permissions.go @@ -29,8 +29,9 @@ func (h *ProfilePermissionsHandler) GetMyPermissions(c *gin.Context) { // Admin gets all permissions by definition. var list []string if role == "admin" { - list = make([]string, len(auth.AllPermissions)) - copy(list, auth.AllPermissions) + all := auth.AllPermissionsWithExtensions() + list = make([]string, len(all)) + copy(list, all) } else { perms, err := auth.ResolvePermissions(ctx, h.stores, userID) if err != nil { diff --git a/server/main.go b/server/main.go index e1824ca..02b135c 100644 --- a/server/main.go +++ b/server/main.go @@ -115,6 +115,7 @@ func main() { // Bootstrap admin from env (K8s secret) — upserts on every restart handlers.BootstrapAdmin(cfg, stores, uekCache) + handlers.BootstrapPAT(cfg, stores) // Seed additional users from env (dev/test only, skipped in production) handlers.SeedUsers(cfg, stores, uekCache) @@ -184,6 +185,10 @@ func main() { handlers.InstallBundledPackages(cfg.BundledPackagesDir, bundledPkgDir, cfg.BundledPackages, stores, starlarkRunner) } + // ── Register extension user permissions ───── + // Scan active packages on boot and populate the dynamic permission registry. + handlers.RegisterAllExtensionUserPermissions(stores) + // ── Trigger Engine ───────────────── triggerEngine := triggers.New(stores, starlarkRunner, bus) if err := triggerEngine.Start(context.Background()); err != nil { @@ -342,7 +347,7 @@ func main() { }) // WebSocket endpoint - base.GET("/ws", middleware.WsAuth(cfg, stores.Users, userCache, ticketAdapter), hub.HandleWebSocket) + base.GET("/ws", middleware.WsAuth(cfg, stores.Users, userCache, ticketAdapter, stores.APITokens), hub.HandleWebSocket) // ── Auth routes (rate limited) ────────────── authMode, err := auth.ParseMode(cfg.AuthMode) @@ -448,12 +453,15 @@ func main() { // Client SDK calls this on user interaction (debounced, max 1/min) // to update last_activity_at for idle-timeout tracking. activityGroup := api.Group("/auth") - activityGroup.Use(middleware.Auth(cfg, stores.Users, userCache)) + activityGroup.Use(middleware.Auth(cfg, stores.Users, userCache, stores.APITokens)) activityGroup.POST("/activity", authH.Activity) + // ── Shared handler instances ────────────── + tokenH := handlers.NewAPITokenHandler(stores) + // ── Protected routes ──────────────────── protected := api.Group("") - protected.Use(middleware.Auth(cfg, stores.Users, userCache)) + protected.Use(middleware.Auth(cfg, stores.Users, userCache, stores.APITokens)) protected.Use(middleware.ValidatePathParams()) { // ── WebSocket Ticket ─────────── @@ -559,6 +567,11 @@ func main() { permH := handlers.NewProfilePermissionsHandler(stores) protected.GET("/profile/permissions", permH.GetMyPermissions) + // API Tokens (PATs) + protected.POST("/auth/tokens", tokenH.CreateToken) + protected.GET("/auth/tokens", tokenH.ListTokens) + protected.DELETE("/auth/tokens/:id", tokenH.RevokeToken) + // Boot payload — single-call SDK bootstrap bootH := handlers.NewProfileBootstrapHandler(stores) protected.GET("/profile/bootstrap", bootH.GetBootstrap) @@ -680,7 +693,7 @@ func main() { // ── Admin routes ──────────────────────── admin := api.Group("/admin") - admin.Use(middleware.Auth(cfg, stores.Users, userCache)) + admin.Use(middleware.Auth(cfg, stores.Users, userCache, stores.APITokens)) admin.Use(middleware.RequireAdmin(stores)) admin.Use(middleware.ValidatePathParams()) { @@ -696,6 +709,9 @@ func main() { admin.POST("/users/:id/vault/reset", adm.ResetVault) admin.DELETE("/users/:id", adm.DeleteUser) + // Admin API Tokens (create tokens for any user) + admin.POST("/tokens", tokenH.AdminCreateToken) + // Global settings admin.GET("/settings", adm.ListGlobalSettings) admin.GET("/settings/:key", adm.GetGlobalSetting) @@ -910,7 +926,7 @@ func main() { { extAPIH := handlers.NewExtAPIHandler(stores, starlarkRunner) extAPI := base.Group("/s/:slug/api") - extAPI.Use(middleware.Auth(cfg, stores.Users, userCache)) + extAPI.Use(middleware.Auth(cfg, stores.Users, userCache, stores.APITokens)) extAPI.Any("/*path", extAPIH.Handle) } diff --git a/server/middleware/auth.go b/server/middleware/auth.go index c7d3808..d9102c6 100644 --- a/server/middleware/auth.go +++ b/server/middleware/auth.go @@ -1,6 +1,7 @@ package middleware import ( + "context" "log" "net/http" "os" @@ -11,6 +12,7 @@ import ( "github.com/gin-gonic/gin" "github.com/golang-jwt/jwt/v5" + "armature/auth" "armature/config" "armature/database" "armature/store" @@ -184,9 +186,15 @@ func UserIDFromCookie(c *gin.Context, jwtSecret string) string { // ─── Auth middleware ───────────────────────────────────────── -// Auth returns a Gin middleware that validates JWT bearer tokens and -// verifies the user is active with their current DB role. -func Auth(cfg *config.Config, users store.UserStore, cache *UserStatusCache) gin.HandlerFunc { +// Auth returns a Gin middleware that validates JWT bearer tokens or personal +// access tokens (PATs) and verifies the user is active. +func Auth(cfg *config.Config, users store.UserStore, cache *UserStatusCache, tokens ...store.APITokenStore) gin.HandlerFunc { + // Optional PAT store — passed as variadic to keep call sites compatible. + var tokenStore store.APITokenStore + if len(tokens) > 0 && tokens[0] != nil { + tokenStore = tokens[0] + } + return func(c *gin.Context) { // Skip auth when running without a database (unmanaged mode) if !database.IsConnected() { @@ -217,6 +225,13 @@ func Auth(cfg *config.Config, users store.UserStore, cache *UserStatusCache) gin return } + // ── PAT path ── + if strings.HasPrefix(tokenString, "arm_pat_") && tokenStore != nil { + authenticatePAT(c, tokenString, tokenStore, users, cache) + return + } + + // ── JWT path ── claims, ok := parseAndValidateJWT(tokenString, cfg.JWTSecret) if !ok { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ @@ -236,6 +251,33 @@ func Auth(cfg *config.Config, users store.UserStore, cache *UserStatusCache) gin } } +// authenticatePAT validates a personal access token and sets context values. +func authenticatePAT(c *gin.Context, tokenString string, tokenStore store.APITokenStore, users store.UserStore, cache *UserStatusCache) { + tokenHash := auth.HashToken(tokenString) + + apiToken, err := tokenStore.GetByHash(c.Request.Context(), tokenHash) + if err != nil || apiToken == nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ + "error": "invalid or expired token", + }) + return + } + + // Verify user is still active + if !verifyUserByID(c, apiToken.UserID, users, cache) { + return + } + + c.Set("user_id", apiToken.UserID) + c.Set("auth_method", "pat") + c.Set("pat_permissions", apiToken.Permissions) + + // Fire-and-forget: update last_used_at + go tokenStore.UpdateLastUsed(context.Background(), apiToken.ID) + + c.Next() +} + // ─── CORS middleware ───────────────────────────────────────── // CORS returns a middleware that sets cross-origin headers. @@ -322,7 +364,7 @@ type TicketValidator interface { // When ?token= is used, a deprecation notice is logged. The ticket // path avoids exposing the JWT in server logs, proxy logs, and // browser history. -func WsAuth(cfg *config.Config, users store.UserStore, cache *UserStatusCache, tickets TicketValidator) gin.HandlerFunc { +func WsAuth(cfg *config.Config, users store.UserStore, cache *UserStatusCache, tickets TicketValidator, tokens ...store.APITokenStore) gin.HandlerFunc { return func(c *gin.Context) { if !database.IsConnected() { c.Next() @@ -389,6 +431,12 @@ func WsAuth(cfg *config.Config, users store.UserStore, cache *UserStatusCache, t return } + // PAT support in WS auth + if strings.HasPrefix(tokenString, "arm_pat_") && len(tokens) > 0 && tokens[0] != nil { + authenticatePAT(c, tokenString, tokens[0], users, cache) + return + } + claims, ok := parseAndValidateJWT(tokenString, cfg.JWTSecret) if !ok { c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{ diff --git a/server/middleware/permissions.go b/server/middleware/permissions.go index ae976f7..a5b07df 100644 --- a/server/middleware/permissions.go +++ b/server/middleware/permissions.go @@ -30,10 +30,26 @@ func RequirePermission(perm string, stores store.Stores) gin.HandlerFunc { } // resolveAndCachePerms loads the user's effective permissions once per request. +// For PAT-authenticated requests, uses the token's stored permissions directly +// (git model: token retains permissions even if user later loses them). func resolveAndCachePerms(c *gin.Context, stores store.Stores, userID string) (map[string]bool, error) { if cached, exists := c.Get(permCacheKey); exists { return cached.(map[string]bool), nil } + + // PAT path: use token's stored permissions directly + if c.GetString("auth_method") == "pat" { + if patPerms, exists := c.Get("pat_permissions"); exists { + perms := make(map[string]bool) + for _, p := range patPerms.([]string) { + perms[p] = true + } + c.Set(permCacheKey, perms) + return perms, nil + } + } + + // JWT path: resolve from groups perms, err := auth.ResolvePermissions(c.Request.Context(), stores, userID) if err != nil { return nil, err diff --git a/server/models/models_api_token.go b/server/models/models_api_token.go new file mode 100644 index 0000000..6ee6078 --- /dev/null +++ b/server/models/models_api_token.go @@ -0,0 +1,49 @@ +package models + +import "time" + +// ========================================= +// API TOKENS (Personal Access Tokens) +// ========================================= + +// APIToken represents a personal access token for programmatic API access. +// The token_hash stores the SHA-256 hash of the raw token string. +// The raw token is only shown once at creation time. +type APIToken struct { + ID string `json:"id" db:"id"` + UserID string `json:"user_id" db:"user_id"` + Name string `json:"name" db:"name"` + TokenHash string `json:"-" db:"token_hash"` // never exposed via JSON + Prefix string `json:"prefix" db:"prefix"` // first 8 hex chars for identification + Permissions []string `json:"permissions" db:"-"` // resolved from JSON column + ExpiresAt *time.Time `json:"expires_at,omitempty" db:"expires_at"` + LastUsedAt *time.Time `json:"last_used_at,omitempty" db:"last_used_at"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + CreatedBy *string `json:"created_by,omitempty" db:"created_by"` +} + +// APITokenCreateRequest is the JSON body for token creation. +type APITokenCreateRequest struct { + Name string `json:"name"` + Permissions []string `json:"permissions"` + ExpiresAt string `json:"expires_at,omitempty"` // ISO 8601 +} + +// AdminTokenCreateRequest extends the create request with a target user. +type AdminTokenCreateRequest struct { + UserID string `json:"user_id"` + Name string `json:"name"` + Permissions []string `json:"permissions"` + ExpiresAt string `json:"expires_at,omitempty"` +} + +// APITokenCreateResponse includes the raw token (shown once). +type APITokenCreateResponse struct { + Token string `json:"token"` // raw token, shown once + ID string `json:"id"` + Name string `json:"name"` + Prefix string `json:"prefix"` + Permissions []string `json:"permissions"` + ExpiresAt *time.Time `json:"expires_at,omitempty"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/server/sandbox/permissions_module.go b/server/sandbox/permissions_module.go new file mode 100644 index 0000000..055aa7f --- /dev/null +++ b/server/sandbox/permissions_module.go @@ -0,0 +1,44 @@ +// Package sandbox — permissions_module.go +// +// Read-only module for checking user permissions from Starlark scripts. +// No sandbox permission required — extensions can check whether a user +// has a specific permission without needing any special grants. +// +// Starlark API: +// permissions.check(user_id, "image-gen.use") → True/False +package sandbox + +import ( + "context" + + "go.starlark.net/starlark" + "go.starlark.net/starlarkstruct" + + "armature/auth" + "armature/store" +) + +// BuildPermissionsModule creates the "permissions" module. +// Always available to all extensions (no permission gate). +func BuildPermissionsModule(ctx context.Context, stores store.Stores) *starlarkstruct.Module { + return MakeModule("permissions", starlark.StringDict{ + "check": starlark.NewBuiltin("permissions.check", func( + thread *starlark.Thread, b *starlark.Builtin, + args starlark.Tuple, kwargs []starlark.Tuple, + ) (starlark.Value, error) { + var userID, perm string + if err := starlark.UnpackPositionalArgs(b.Name(), args, kwargs, 2, &userID, &perm); err != nil { + return nil, err + } + + perms, err := auth.ResolvePermissions(ctx, stores, userID) + if err != nil { + return starlark.False, nil + } + if perms[perm] { + return starlark.True, nil + } + return starlark.False, nil + }), + }) +} diff --git a/server/sandbox/runner.go b/server/sandbox/runner.go index b8925ff..88827e7 100644 --- a/server/sandbox/runner.go +++ b/server/sandbox/runner.go @@ -396,6 +396,9 @@ func (r *Runner) buildModulesWithLibCtx(ctx context.Context, packageID string, m } modules["settings"] = BuildSettingsModule(ctx, r.stores, packageID, userID, teamID) + // Always available — read-only check against kernel permission data + modules["permissions"] = BuildPermissionsModule(ctx, r.stores) + // Allows any starlark package to load declared library dependencies. if lc != nil { modules["lib"] = BuildLibModule(ctx, r, packageID, rc, lc) diff --git a/server/store/api_token_iface.go b/server/store/api_token_iface.go new file mode 100644 index 0000000..022f8e9 --- /dev/null +++ b/server/store/api_token_iface.go @@ -0,0 +1,34 @@ +package store + +import ( + "context" + + "armature/models" +) + +// APITokenStore manages personal access tokens for programmatic API access. +type APITokenStore interface { + // Create inserts a new API token. The token_hash must be a SHA-256 hash + // of the raw token string. ID and CreatedAt are set by the implementation. + Create(ctx context.Context, token *models.APIToken) error + + // GetByHash retrieves a non-expired token by its SHA-256 hash. + // Returns nil, nil if not found or expired. + GetByHash(ctx context.Context, tokenHash string) (*models.APIToken, error) + + // ListForUser returns all tokens belonging to a user, ordered by created_at DESC. + ListForUser(ctx context.Context, userID string) ([]models.APIToken, error) + + // Revoke deletes a token owned by the specified user. + // Returns the number of rows affected (0 if not found or not owned). + Revoke(ctx context.Context, id, userID string) (int64, error) + + // RevokeByID deletes a token by ID regardless of owner (admin use). + RevokeByID(ctx context.Context, id string) (int64, error) + + // CleanExpired deletes all tokens past their expires_at. Returns rows deleted. + CleanExpired(ctx context.Context) (int64, error) + + // UpdateLastUsed sets last_used_at to now for the given token ID. + UpdateLastUsed(ctx context.Context, id string) error +} diff --git a/server/store/interfaces.go b/server/store/interfaces.go index 39368d6..8af27a9 100644 --- a/server/store/interfaces.go +++ b/server/store/interfaces.go @@ -62,6 +62,7 @@ type Stores struct { Triggers TriggerStore ScheduledTasks ScheduledTaskStore Cluster ClusterStore + APITokens APITokenStore } // TeamAvailableModel is returned by CatalogStore.ListTeamAvailable. diff --git a/server/store/postgres/api_tokens.go b/server/store/postgres/api_tokens.go new file mode 100644 index 0000000..f4e5f45 --- /dev/null +++ b/server/store/postgres/api_tokens.go @@ -0,0 +1,125 @@ +package postgres + +import ( + "context" + "database/sql" + "encoding/json" + "time" + + "armature/models" +) + +// APITokenStore implements store.APITokenStore for PostgreSQL. +type APITokenStore struct { + db *sql.DB +} + +// NewAPITokenStore creates a new PostgreSQL API token store. +func NewAPITokenStore(db *sql.DB) *APITokenStore { + return &APITokenStore{db: db} +} + +func (s *APITokenStore) Create(ctx context.Context, token *models.APIToken) error { + permsJSON, err := json.Marshal(token.Permissions) + if err != nil { + return err + } + + return s.db.QueryRowContext(ctx, + `INSERT INTO api_tokens (user_id, name, token_hash, prefix, permissions, expires_at, created_by) + VALUES ($1, $2, $3, $4, $5::jsonb, $6, $7) + RETURNING id, created_at`, + token.UserID, token.Name, token.TokenHash, token.Prefix, + string(permsJSON), token.ExpiresAt, token.CreatedBy, + ).Scan(&token.ID, &token.CreatedAt) +} + +func (s *APITokenStore) GetByHash(ctx context.Context, tokenHash string) (*models.APIToken, error) { + row := s.db.QueryRowContext(ctx, + `SELECT id, user_id, name, token_hash, prefix, permissions, expires_at, last_used_at, created_at, created_by + FROM api_tokens + WHERE token_hash = $1 AND (expires_at IS NULL OR expires_at > NOW())`, + tokenHash) + + var t models.APIToken + var permsJSON []byte + err := row.Scan(&t.ID, &t.UserID, &t.Name, &t.TokenHash, &t.Prefix, + &permsJSON, &t.ExpiresAt, &t.LastUsedAt, &t.CreatedAt, &t.CreatedBy) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + + if err := json.Unmarshal(permsJSON, &t.Permissions); err != nil { + t.Permissions = []string{} + } + return &t, nil +} + +func (s *APITokenStore) ListForUser(ctx context.Context, userID string) ([]models.APIToken, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, user_id, name, prefix, permissions, expires_at, last_used_at, created_at, created_by + FROM api_tokens + WHERE user_id = $1 + ORDER BY created_at DESC`, + userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var tokens []models.APIToken + for rows.Next() { + var t models.APIToken + var permsJSON []byte + if err := rows.Scan(&t.ID, &t.UserID, &t.Name, &t.Prefix, + &permsJSON, &t.ExpiresAt, &t.LastUsedAt, &t.CreatedAt, &t.CreatedBy); err != nil { + return nil, err + } + if err := json.Unmarshal(permsJSON, &t.Permissions); err != nil { + t.Permissions = []string{} + } + tokens = append(tokens, t) + } + if tokens == nil { + tokens = []models.APIToken{} + } + return tokens, nil +} + +func (s *APITokenStore) Revoke(ctx context.Context, id, userID string) (int64, error) { + res, err := s.db.ExecContext(ctx, + `DELETE FROM api_tokens WHERE id = $1 AND user_id = $2`, + id, userID) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +func (s *APITokenStore) RevokeByID(ctx context.Context, id string) (int64, error) { + res, err := s.db.ExecContext(ctx, + `DELETE FROM api_tokens WHERE id = $1`, id) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +func (s *APITokenStore) CleanExpired(ctx context.Context) (int64, error) { + res, err := s.db.ExecContext(ctx, + `DELETE FROM api_tokens WHERE expires_at IS NOT NULL AND expires_at < NOW()`) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +func (s *APITokenStore) UpdateLastUsed(ctx context.Context, id string) error { + _, err := s.db.ExecContext(ctx, + `UPDATE api_tokens SET last_used_at = $1 WHERE id = $2`, + time.Now(), id) + return err +} diff --git a/server/store/postgres/stores.go b/server/store/postgres/stores.go index e460fac..96c5619 100644 --- a/server/store/postgres/stores.go +++ b/server/store/postgres/stores.go @@ -33,5 +33,6 @@ func NewStores(db *sql.DB) store.Stores { Triggers: NewTriggerStore(), ScheduledTasks: NewScheduledTaskStore(), Cluster: NewClusterStore(), + APITokens: NewAPITokenStore(db), } } diff --git a/server/store/sqlite/api_tokens.go b/server/store/sqlite/api_tokens.go new file mode 100644 index 0000000..e7c3dd5 --- /dev/null +++ b/server/store/sqlite/api_tokens.go @@ -0,0 +1,172 @@ +package sqlite + +import ( + "context" + "database/sql" + "encoding/json" + "time" + + "armature/models" + + "github.com/google/uuid" +) + +// APITokenStore implements store.APITokenStore for SQLite. +type APITokenStore struct{} + +// NewAPITokenStore creates a new SQLite API token store. +func NewAPITokenStore() *APITokenStore { + return &APITokenStore{} +} + +func (s *APITokenStore) Create(ctx context.Context, token *models.APIToken) error { + token.ID = uuid.New().String() + token.CreatedAt = time.Now().UTC() + + permsJSON, err := json.Marshal(token.Permissions) + if err != nil { + return err + } + + _, err = DB.ExecContext(ctx, + `INSERT INTO api_tokens (id, user_id, name, token_hash, prefix, permissions, expires_at, created_by, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + token.ID, token.UserID, token.Name, token.TokenHash, token.Prefix, + string(permsJSON), formatNullableTime(token.ExpiresAt), token.CreatedBy, token.CreatedAt.UTC().Format(time.RFC3339), + ) + return err +} + +func (s *APITokenStore) GetByHash(ctx context.Context, tokenHash string) (*models.APIToken, error) { + row := DB.QueryRowContext(ctx, + `SELECT id, user_id, name, token_hash, prefix, permissions, expires_at, last_used_at, created_at, created_by + FROM api_tokens + WHERE token_hash = ? AND (expires_at IS NULL OR expires_at > datetime('now'))`, + tokenHash) + + var t models.APIToken + var permsJSON string + var expiresAt, lastUsedAt, createdAt sql.NullString + var createdBy sql.NullString + err := row.Scan(&t.ID, &t.UserID, &t.Name, &t.TokenHash, &t.Prefix, + &permsJSON, &expiresAt, &lastUsedAt, &createdAt, &createdBy) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + + if err := json.Unmarshal([]byte(permsJSON), &t.Permissions); err != nil { + t.Permissions = []string{} + } + t.ExpiresAt = parseNullableTime(expiresAt) + t.LastUsedAt = parseNullableTime(lastUsedAt) + if createdAt.Valid { + if ts, err := time.Parse(time.RFC3339, createdAt.String); err == nil { + t.CreatedAt = ts + } + } + if createdBy.Valid { + t.CreatedBy = &createdBy.String + } + return &t, nil +} + +func (s *APITokenStore) ListForUser(ctx context.Context, userID string) ([]models.APIToken, error) { + rows, err := DB.QueryContext(ctx, + `SELECT id, user_id, name, prefix, permissions, expires_at, last_used_at, created_at, created_by + FROM api_tokens + WHERE user_id = ? + ORDER BY created_at DESC`, + userID) + if err != nil { + return nil, err + } + defer rows.Close() + + var tokens []models.APIToken + for rows.Next() { + var t models.APIToken + var permsJSON string + var expiresAt, lastUsedAt, createdAt sql.NullString + var createdBy sql.NullString + if err := rows.Scan(&t.ID, &t.UserID, &t.Name, &t.Prefix, + &permsJSON, &expiresAt, &lastUsedAt, &createdAt, &createdBy); err != nil { + return nil, err + } + if err := json.Unmarshal([]byte(permsJSON), &t.Permissions); err != nil { + t.Permissions = []string{} + } + t.ExpiresAt = parseNullableTime(expiresAt) + t.LastUsedAt = parseNullableTime(lastUsedAt) + if createdAt.Valid { + if ts, err := time.Parse(time.RFC3339, createdAt.String); err == nil { + t.CreatedAt = ts + } + } + if createdBy.Valid { + t.CreatedBy = &createdBy.String + } + tokens = append(tokens, t) + } + if tokens == nil { + tokens = []models.APIToken{} + } + return tokens, nil +} + +func (s *APITokenStore) Revoke(ctx context.Context, id, userID string) (int64, error) { + res, err := DB.ExecContext(ctx, + `DELETE FROM api_tokens WHERE id = ? AND user_id = ?`, + id, userID) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +func (s *APITokenStore) RevokeByID(ctx context.Context, id string) (int64, error) { + res, err := DB.ExecContext(ctx, + `DELETE FROM api_tokens WHERE id = ?`, id) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +func (s *APITokenStore) CleanExpired(ctx context.Context) (int64, error) { + res, err := DB.ExecContext(ctx, + `DELETE FROM api_tokens WHERE expires_at IS NOT NULL AND expires_at < datetime('now')`) + if err != nil { + return 0, err + } + return res.RowsAffected() +} + +func (s *APITokenStore) UpdateLastUsed(ctx context.Context, id string) error { + _, err := DB.ExecContext(ctx, + `UPDATE api_tokens SET last_used_at = ? WHERE id = ?`, + time.Now().UTC().Format(time.RFC3339), id) + return err +} + +// ── Helpers ────────────────────────────────── + +func formatNullableTime(t *time.Time) interface{} { + if t == nil { + return nil + } + return t.UTC().Format(time.RFC3339) +} + +func parseNullableTime(ns sql.NullString) *time.Time { + if !ns.Valid || ns.String == "" { + return nil + } + t, err := time.Parse(time.RFC3339, ns.String) + if err != nil { + return nil + } + return &t +} diff --git a/server/store/sqlite/stores.go b/server/store/sqlite/stores.go index 8605eb6..fb0f91e 100644 --- a/server/store/sqlite/stores.go +++ b/server/store/sqlite/stores.go @@ -32,5 +32,6 @@ func NewStores(db *sql.DB) store.Stores { RateLimits: NewRateLimitStore(), Triggers: NewTriggerStore(), ScheduledTasks: NewScheduledTaskStore(), + APITokens: NewAPITokenStore(), } } diff --git a/src/js/sw/surfaces/admin/groups.js b/src/js/sw/surfaces/admin/groups.js index 9851984..3fb33f6 100644 --- a/src/js/sw/surfaces/admin/groups.js +++ b/src/js/sw/surfaces/admin/groups.js @@ -10,6 +10,7 @@ export default function GroupsSection() { const [detail, setDetail] = useState(null); const [members, setMembers] = useState([]); const [allPerms, setAllPerms] = useState([]); + const [groupedPerms, setGroupedPerms] = useState({}); const [allUsers, setAllUsers] = useState([]); const [groupData, setGroupData] = useState({}); const [showCreate, setShowCreate] = useState(false); @@ -41,6 +42,7 @@ export default function GroupsSection() { ]); setMembers(m || []); setAllPerms(p.permissions || []); + setGroupedPerms(p.grouped || {}); setAllUsers(u || []); } catch (e) { sw.toast(e.message, 'error'); } } @@ -131,16 +133,37 @@ export default function GroupsSection() {
Permissions
-
- ${allPerms.map(p => html` - - `)} -
+ ${Object.keys(groupedPerms).length > 0 + ? Object.entries(groupedPerms).map(([source, perms]) => html` +
+
+ ${source === 'kernel' ? 'Platform' : source} +
+
+ ${perms.map(p => html` + + `)} +
+
+ `) + : html` +
+ ${allPerms.map(p => html` + + `)} +
+ ` + }
diff --git a/src/js/sw/surfaces/admin/users.js b/src/js/sw/surfaces/admin/users.js index 9e6e23b..67bc994 100644 --- a/src/js/sw/surfaces/admin/users.js +++ b/src/js/sw/surfaces/admin/users.js @@ -87,6 +87,17 @@ export default function UsersSection() { } catch (e) { sw.toast(e.message, 'error'); } } + async function createTokenForUser(userId, username) { + const name = await sw.prompt(`Token name for "${username}":`, ''); + if (!name) return; + try { + const resp = await sw.api.fetch('POST', '/admin/tokens', { + user_id: userId, name, permissions: [], + }); + await sw.alert(`Token created for ${username}. Copy it now — it won't be shown again:\n\n${resp.token}`); + } catch (e) { sw.toast(e.message || 'Failed to create token', 'error'); } + } + function statusBadge(u) { if (u.status === 'pending') return html`pending`; if (!u.is_active) return html`inactive`; @@ -142,6 +153,7 @@ export default function UsersSection() { : html`` } +
diff --git a/src/js/sw/surfaces/settings/index.js b/src/js/sw/surfaces/settings/index.js index c05db07..677ff3d 100644 --- a/src/js/sw/surfaces/settings/index.js +++ b/src/js/sw/surfaces/settings/index.js @@ -25,6 +25,7 @@ const sectionModules = { teams: () => import('./teams.js'), connections: () => import('./connections.js'), notifications: () => import('./notifications.js'), + tokens: () => import('./tokens.js'), }; // ── Nav structure ─────────────────────────── @@ -35,6 +36,7 @@ const NAV_ITEMS = [ { key: 'teams', label: 'Teams' }, { key: 'connections', label: 'Connections' }, { key: 'notifications', label: 'Notifications' }, + { key: 'tokens', label: 'API Tokens' }, ]; // ── Section title map ─────────────────────── @@ -42,6 +44,7 @@ const SECTION_TITLES = { general: 'General', appearance: 'Appearance', profile: 'Profile', teams: 'Teams', connections: 'Connections', notifications: 'Notifications', + tokens: 'API Tokens', }; // ── Extension config sections ────── diff --git a/src/js/sw/surfaces/settings/tokens.js b/src/js/sw/surfaces/settings/tokens.js new file mode 100644 index 0000000..b23a20f --- /dev/null +++ b/src/js/sw/surfaces/settings/tokens.js @@ -0,0 +1,256 @@ +/** + * TokensSection — personal access token CRUD + * + * Create, list, and revoke PATs for programmatic API access. + * Token value is shown once on creation; stored as SHA-256 hash server-side. + */ +const { html } = window; +const { useState, useEffect, useCallback } = hooks; + +function esc(s) { return s == null ? '' : String(s); } + +export function TokensSection() { + const [tokens, setTokens] = useState(null); + const [availablePerms, setAvailablePerms] = useState([]); + const [showForm, setShowForm] = useState(false); + const [createdToken, setCreatedToken] = useState(null); + const [form, setForm] = useState({ name: '', permissions: [], expires_at: '' }); + const [saving, setSaving] = useState(false); + const [copied, setCopied] = useState(false); + + const load = useCallback(async () => { + try { + const resp = await sw.api.fetch('GET', '/auth/tokens'); + setTokens(resp?.data || []); + } catch (e) { + setTokens([]); + } + }, []); + + const loadPerms = useCallback(async () => { + try { + const resp = await sw.api.fetch('GET', '/profile/permissions'); + setAvailablePerms(resp?.permissions || []); + } catch (_) {} + }, []); + + useEffect(() => { load(); loadPerms(); }, [load, loadPerms]); + + const openCreate = useCallback(() => { + setCreatedToken(null); + setForm({ name: '', permissions: [], expires_at: '' }); + setShowForm(true); + }, []); + + const cancel = useCallback(() => { + setShowForm(false); + setCreatedToken(null); + }, []); + + const togglePerm = useCallback((perm) => { + setForm(f => { + const perms = f.permissions.includes(perm) + ? f.permissions.filter(p => p !== perm) + : [...f.permissions, perm]; + return { ...f, permissions: perms }; + }); + }, []); + + const create = useCallback(async () => { + if (!form.name) { + sw.emit('toast', { message: 'Token name is required', variant: 'error' }); + return; + } + setSaving(true); + try { + const body = { name: form.name, permissions: form.permissions }; + if (form.expires_at) body.expires_at = new Date(form.expires_at).toISOString(); + const resp = await sw.api.fetch('POST', '/auth/tokens', body); + setCreatedToken(resp.token); + setShowForm(false); + load(); + sw.emit('toast', { message: 'Token created', variant: 'success' }); + } catch (e) { + sw.emit('toast', { message: e.message || 'Failed to create token', variant: 'error' }); + } finally { + setSaving(false); + } + }, [form, load]); + + const revoke = useCallback(async (id, name) => { + if (!await sw.confirm(`Revoke token "${esc(name)}"? This action cannot be undone.`)) return; + try { + await sw.api.fetch('DELETE', `/auth/tokens/${id}`); + sw.emit('toast', { message: 'Token revoked', variant: 'success' }); + load(); + } catch (e) { + sw.emit('toast', { message: e.message || 'Failed to revoke', variant: 'error' }); + } + }, [load]); + + const copyToken = useCallback(async () => { + if (!createdToken) return; + try { + await navigator.clipboard.writeText(createdToken); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (_) { + sw.emit('toast', { message: 'Copy failed — select and copy manually', variant: 'error' }); + } + }, [createdToken]); + + const dismissToken = useCallback(() => { setCreatedToken(null); }, []); + + // ── Render ────────────────────────────── + + if (tokens === null) return html`

Loading…

`; + + return html` +
+ ${createdToken && html` +
+ Token created — copy it now. It won't be shown again. +
+ ${createdToken} + +
+ +
+ `} + +
+

+ Personal access tokens provide programmatic API access. + Treat tokens like passwords. +

+ +
+ + ${showForm && html` +
+
+ + setForm(f => ({ ...f, name: e.target.value }))} /> +
+ +
+ + setForm(f => ({ ...f, expires_at: e.target.value }))} /> +
+ +
+ +

+ Select the permissions this token should have. Leave empty for read-only access. +

+
+ ${availablePerms.map(p => html` + + `)} +
+
+ +
+ + +
+
+ `} + + ${tokens.length === 0 && !showForm && html` +
+

No tokens yet

+

Create a personal access token for CI, scripts, or API integration.

+
+ `} + + ${tokens.length > 0 && html` + + + + + + + + + + + + + ${tokens.map(t => html` + + + + + + + + + `)} + +
NameTokenPermissionsExpiresLast Used
${esc(t.name)}arm_pat_${esc(t.prefix)}… + ${(t.permissions || []).length === 0 + ? html`none` + : (t.permissions || []).map(p => html` + ${p} + `) + } + ${t.expires_at ? new Date(t.expires_at).toLocaleDateString() : '—'}${t.last_used_at ? new Date(t.last_used_at).toLocaleDateString() : 'Never'} + +
+ `} +
+ + + `; +} + +export default TokensSection; -- 2.49.1 From a441f206e3de1f25ead669d55e2082bf7a192909 Mon Sep 17 00:00:00 2001 From: Jeffrey Smith Date: Thu, 2 Apr 2026 18:25:59 +0000 Subject: [PATCH 2/2] Fix token UI to use SDK rest client (sw.api.get/post/del) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The frontend SDK doesn't expose sw.api.fetch — use sw.api.get(), sw.api.post(), sw.api.del() with full /api/v1/ paths instead. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/js/sw/surfaces/admin/users.js | 2 +- src/js/sw/surfaces/settings/tokens.js | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/js/sw/surfaces/admin/users.js b/src/js/sw/surfaces/admin/users.js index 67bc994..041cc8a 100644 --- a/src/js/sw/surfaces/admin/users.js +++ b/src/js/sw/surfaces/admin/users.js @@ -91,7 +91,7 @@ export default function UsersSection() { const name = await sw.prompt(`Token name for "${username}":`, ''); if (!name) return; try { - const resp = await sw.api.fetch('POST', '/admin/tokens', { + const resp = await sw.api.post('/api/v1/admin/tokens', { user_id: userId, name, permissions: [], }); await sw.alert(`Token created for ${username}. Copy it now — it won't be shown again:\n\n${resp.token}`); diff --git a/src/js/sw/surfaces/settings/tokens.js b/src/js/sw/surfaces/settings/tokens.js index b23a20f..a741cb8 100644 --- a/src/js/sw/surfaces/settings/tokens.js +++ b/src/js/sw/surfaces/settings/tokens.js @@ -20,8 +20,8 @@ export function TokensSection() { const load = useCallback(async () => { try { - const resp = await sw.api.fetch('GET', '/auth/tokens'); - setTokens(resp?.data || []); + const resp = await sw.api.get('/api/v1/auth/tokens'); + setTokens(resp || []); } catch (e) { setTokens([]); } @@ -29,7 +29,7 @@ export function TokensSection() { const loadPerms = useCallback(async () => { try { - const resp = await sw.api.fetch('GET', '/profile/permissions'); + const resp = await sw.api.get('/api/v1/profile/permissions'); setAvailablePerms(resp?.permissions || []); } catch (_) {} }, []); @@ -65,7 +65,7 @@ export function TokensSection() { try { const body = { name: form.name, permissions: form.permissions }; if (form.expires_at) body.expires_at = new Date(form.expires_at).toISOString(); - const resp = await sw.api.fetch('POST', '/auth/tokens', body); + const resp = await sw.api.post('/api/v1/auth/tokens', body); setCreatedToken(resp.token); setShowForm(false); load(); @@ -80,7 +80,7 @@ export function TokensSection() { const revoke = useCallback(async (id, name) => { if (!await sw.confirm(`Revoke token "${esc(name)}"? This action cannot be undone.`)) return; try { - await sw.api.fetch('DELETE', `/auth/tokens/${id}`); + await sw.api.del(`/api/v1/auth/tokens/${id}`); sw.emit('toast', { message: 'Token revoked', variant: 'success' }); load(); } catch (e) { -- 2.49.1