Feat v0.7.7 api tokens ext permissions (#61)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-runners (push) Has been skipped
CI/CD / test-go-pg (push) Successful in 2m48s
CI/CD / test-sqlite (push) Successful in 2m49s
CI/CD / build-and-deploy (push) Successful in 28s
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / e2e-smoke (push) Has been skipped
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-runners (push) Has been skipped
CI/CD / test-go-pg (push) Successful in 2m48s
CI/CD / test-sqlite (push) Successful in 2m49s
CI/CD / build-and-deploy (push) Successful in 28s
Co-authored-by: Jeffrey Smith <jasafpro@gmail.com> Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #61.
This commit is contained in:
36
CHANGELOG.md
36
CHANGELOG.md
@@ -2,6 +2,42 @@
|
|||||||
|
|
||||||
All notable changes to Armature are documented here.
|
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
|
## v0.7.6 — Code Hygiene + Test Coverage
|
||||||
|
|
||||||
**Critical Fixes**
|
**Critical Fixes**
|
||||||
|
|||||||
34
ROADMAP.md
34
ROADMAP.md
@@ -1,6 +1,6 @@
|
|||||||
# Armature — Roadmap
|
# 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
|
Self-hosted extensible platform kernel. Auth, identity, packages, Starlark
|
||||||
sandbox, storage, realtime, and ops are kernel primitives. Everything else
|
sandbox, storage, realtime, and ops are kernel primitives. Everything else
|
||||||
@@ -246,27 +246,27 @@ sidecar auth (v0.10.x).
|
|||||||
|
|
||||||
| Step | Status | Description |
|
| 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. |
|
| `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 | | `Create`, `GetByHash`, `ListForUser`, `Revoke`, `CleanExpired`, `UpdateLastUsed`. |
|
| Token store interface | done | `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 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 | | `GET /api/v1/auth/tokens` (list mine), `DELETE /api/v1/auth/tokens/:id` (revoke). |
|
| Token management endpoints | done | `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. |
|
| Admin token endpoint | done | `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. |
|
| 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 | | 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). |
|
| 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 | | Settings → API Tokens tab. Create, list, revoke. Show permissions, expiry, last used. Token value shown once on creation. |
|
| Settings UI | done | 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. |
|
| Admin UI | done | 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. |
|
| 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**
|
**Extension-Declared User Permissions**
|
||||||
|
|
||||||
| Step | Status | Description |
|
| Step | Status | Description |
|
||||||
|------|--------|-------------|
|
|------|--------|-------------|
|
||||||
| `user_permissions` manifest field | | Extensions declare permissions users need: `"user_permissions": ["image-gen.use", "image-gen.admin"]`. |
|
| `user_permissions` manifest field | done | 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`. |
|
| Dynamic permission registry | done | 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. |
|
| Group UI update | done | 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). |
|
| `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 | | Optional. If set, `ext_api.go` checks this permission before calling `on_request`. Extension doesn't execute for unauthorized users. |
|
| `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 | | `ext_api.go` resolves user permissions and includes them in the request dict. Extensions can check inline without the module. |
|
| `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
|
### v0.7.8 — Deferred Test Coverage
|
||||||
|
|
||||||
|
|||||||
@@ -32,11 +32,22 @@ echo " Server: ${SERVER_URL}"
|
|||||||
mkdir -p "${SCREENSHOT_DIR}"
|
mkdir -p "${SCREENSHOT_DIR}"
|
||||||
|
|
||||||
# ── Authenticate ─────────────────────────────
|
# ── Authenticate ─────────────────────────────
|
||||||
echo -e "${YELLOW}Authenticating...${NC}"
|
# Try PAT first (from bootstrap), fall back to login
|
||||||
TOKEN=$(curl -sf -X POST "${SERVER_URL}/api/v1/auth/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" \
|
-H "Content-Type: application/json" \
|
||||||
-d "{\"login\":\"${ADMIN_USER}\",\"password\":\"${ADMIN_PASS}\"}" \
|
-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)}})")
|
| 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
|
if [ -z "$TOKEN" ]; then
|
||||||
echo -e "${RED}Failed to authenticate${NC}"
|
echo -e "${RED}Failed to authenticate${NC}"
|
||||||
|
|||||||
13
server/auth/hash.go
Normal file
13
server/auth/hash.go
Normal file
@@ -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[:])
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log"
|
"log"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"armature/database"
|
"armature/database"
|
||||||
"armature/store"
|
"armature/store"
|
||||||
@@ -30,9 +31,8 @@ const (
|
|||||||
PermTokenUnlimited = "token.unlimited" // bypass token budgets
|
PermTokenUnlimited = "token.unlimited" // bypass token budgets
|
||||||
)
|
)
|
||||||
|
|
||||||
// AllPermissions is the complete set of valid permission strings.
|
// KernelPermissions is the static set of platform permission strings.
|
||||||
// Used for validation in handlers and rendering checkboxes in admin UI.
|
var KernelPermissions = []string{
|
||||||
var AllPermissions = []string{
|
|
||||||
PermSurfaceAdminAccess,
|
PermSurfaceAdminAccess,
|
||||||
PermExtensionUse,
|
PermExtensionUse,
|
||||||
PermExtensionInstall,
|
PermExtensionInstall,
|
||||||
@@ -42,6 +42,63 @@ var AllPermissions = []string{
|
|||||||
PermTokenUnlimited,
|
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 ──────────────────────────────
|
// ── Resolution ──────────────────────────────
|
||||||
|
|
||||||
// ResolvePermissions returns the effective permission set for a user.
|
// ResolvePermissions returns the effective permission set for a user.
|
||||||
|
|||||||
81
server/auth/permissions_test.go
Normal file
81
server/auth/permissions_test.go
Normal file
@@ -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()
|
||||||
|
}
|
||||||
20
server/database/migrations/postgres/015_api_tokens.sql
Normal file
20
server/database/migrations/postgres/015_api_tokens.sql
Normal file
@@ -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;
|
||||||
18
server/database/migrations/sqlite/015_api_tokens.sql
Normal file
18
server/database/migrations/sqlite/015_api_tokens.sql
Normal file
@@ -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);
|
||||||
260
server/handlers/api_tokens.go
Normal file
260
server/handlers/api_tokens.go
Normal file
@@ -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 }
|
||||||
267
server/handlers/api_tokens_test.go
Normal file
267
server/handlers/api_tokens_test.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -2,13 +2,12 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -384,8 +383,7 @@ func (h *AuthHandler) generateTokens(user *models.User, keepLogin bool) (gin.H,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func hashToken(token string) string {
|
func hashToken(token string) string {
|
||||||
h := sha256.Sum256([]byte(token))
|
return auth.HashToken(token)
|
||||||
return hex.EncodeToString(h[:])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Vault Lifecycle ─────────────────────────
|
// ── 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)
|
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.
|
// SeedUsers creates or updates users from the SEED_USERS env var.
|
||||||
// Format: "user:pass[:admin],user2:pass2" — third field "admin" adds to Admins group.
|
// Format: "user:pass[:admin],user2:pass2" — third field "admin" adds to Admins group.
|
||||||
// Upsert: existing users get their password refreshed on every restart.
|
// Upsert: existing users get their password refreshed on every restart.
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"go.starlark.net/starlark"
|
"go.starlark.net/starlark"
|
||||||
|
|
||||||
|
"armature/auth"
|
||||||
"armature/models"
|
"armature/models"
|
||||||
"armature/sandbox"
|
"armature/sandbox"
|
||||||
"armature/store"
|
"armature/store"
|
||||||
@@ -125,8 +126,17 @@ func (h *ExtAPIHandler) Handle(c *gin.Context) {
|
|||||||
return
|
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 ──────────────────
|
// ── 5. Build request dict ──────────────────
|
||||||
reqDict, err := buildRequestDict(c, rawPath)
|
reqDict, err := buildRequestDict(c, rawPath, h.stores)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read request: " + err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read request: " + err.Error()})
|
||||||
return
|
return
|
||||||
@@ -228,7 +238,7 @@ func matchAPIRoute(manifest map[string]any, method, path string) bool {
|
|||||||
// "body": "...",
|
// "body": "...",
|
||||||
// "user_id": "uuid",
|
// "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)
|
// Read body (capped at 1 MB for safety)
|
||||||
body, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20))
|
body, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20))
|
||||||
if err != nil {
|
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("method"), starlark.String(c.Request.Method))
|
||||||
_ = d.SetKey(starlark.String("path"), starlark.String(path))
|
_ = d.SetKey(starlark.String("path"), starlark.String(path))
|
||||||
_ = d.SetKey(starlark.String("headers"), hdrs)
|
_ = d.SetKey(starlark.String("headers"), hdrs)
|
||||||
_ = d.SetKey(starlark.String("query"), query)
|
_ = d.SetKey(starlark.String("query"), query)
|
||||||
_ = d.SetKey(starlark.String("body"), starlark.String(string(body)))
|
_ = d.SetKey(starlark.String("body"), starlark.String(string(body)))
|
||||||
_ = d.SetKey(starlark.String("user_id"), starlark.String(getUserID(c)))
|
_ = 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
|
return d, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"armature/auth"
|
||||||
"armature/models"
|
"armature/models"
|
||||||
"armature/store"
|
"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 ──────────────
|
// ── Manifest Permission Parsing ──────────────
|
||||||
|
|
||||||
// SyncManifestPermissions extracts the "permissions" array from a
|
// 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
|
// Package needs review before activation
|
||||||
_ = stores.Packages.SetStatus(c.Request.Context(), pkgID, models.PackageStatusPendingReview)
|
_ = stores.Packages.SetStatus(c.Request.Context(), pkgID, models.PackageStatusPendingReview)
|
||||||
|
|
||||||
|
// Register user-facing permissions in the dynamic permission registry
|
||||||
|
SyncUserPermissions(manifest, pkgID)
|
||||||
|
|
||||||
return perms
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ func (h *GroupHandler) UpdateGroup(c *gin.Context) {
|
|||||||
|
|
||||||
// Validate permissions if provided
|
// Validate permissions if provided
|
||||||
if patch.Permissions != nil {
|
if patch.Permissions != nil {
|
||||||
valid := auth.AllPermissions
|
valid := auth.AllPermissionsWithExtensions()
|
||||||
validSet := make(map[string]bool, len(valid))
|
validSet := make(map[string]bool, len(valid))
|
||||||
for _, p := range valid {
|
for _, p := range valid {
|
||||||
validSet[p] = true
|
validSet[p] = true
|
||||||
@@ -433,7 +433,10 @@ func (h *GroupHandler) DeleteResourceGrant(c *gin.Context) {
|
|||||||
// ListPermissions returns all valid permission strings.
|
// ListPermissions returns all valid permission strings.
|
||||||
// GET /api/v1/admin/permissions
|
// GET /api/v1/admin/permissions
|
||||||
func (h *GroupHandler) ListPermissions(c *gin.Context) {
|
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.
|
// GetUserPermissions returns the effective permissions for a given user.
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ type ManifestInfo struct {
|
|||||||
Dependencies map[string]any
|
Dependencies map[string]any
|
||||||
Requires []string
|
Requires []string
|
||||||
Signature string // reserved for future package signing
|
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,
|
// 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)
|
info.SchemaVersion = ParseSchemaVersion(manifest)
|
||||||
|
|
||||||
// ── Type-specific constraints ────────────────────────────────
|
// ── Type-specific constraints ────────────────────────────────
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"go.starlark.net/starlark"
|
"go.starlark.net/starlark"
|
||||||
|
|
||||||
|
"armature/auth"
|
||||||
"armature/database"
|
"armature/database"
|
||||||
"armature/events"
|
"armature/events"
|
||||||
"armature/models"
|
"armature/models"
|
||||||
@@ -193,6 +194,9 @@ func (h *PackageHandler) DeletePackage(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Unregister user-facing permissions from the dynamic registry
|
||||||
|
auth.UnregisterExtensionPermissions(id)
|
||||||
|
|
||||||
// Clean up extracted static assets
|
// Clean up extracted static assets
|
||||||
if h.packagesDir != "" {
|
if h.packagesDir != "" {
|
||||||
assetDir := filepath.Join(h.packagesDir, id)
|
assetDir := filepath.Join(h.packagesDir, id)
|
||||||
|
|||||||
@@ -29,8 +29,9 @@ func (h *ProfilePermissionsHandler) GetMyPermissions(c *gin.Context) {
|
|||||||
// Admin gets all permissions by definition.
|
// Admin gets all permissions by definition.
|
||||||
var list []string
|
var list []string
|
||||||
if role == "admin" {
|
if role == "admin" {
|
||||||
list = make([]string, len(auth.AllPermissions))
|
all := auth.AllPermissionsWithExtensions()
|
||||||
copy(list, auth.AllPermissions)
|
list = make([]string, len(all))
|
||||||
|
copy(list, all)
|
||||||
} else {
|
} else {
|
||||||
perms, err := auth.ResolvePermissions(ctx, h.stores, userID)
|
perms, err := auth.ResolvePermissions(ctx, h.stores, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ func main() {
|
|||||||
|
|
||||||
// Bootstrap admin from env (K8s secret) — upserts on every restart
|
// Bootstrap admin from env (K8s secret) — upserts on every restart
|
||||||
handlers.BootstrapAdmin(cfg, stores, uekCache)
|
handlers.BootstrapAdmin(cfg, stores, uekCache)
|
||||||
|
handlers.BootstrapPAT(cfg, stores)
|
||||||
|
|
||||||
// Seed additional users from env (dev/test only, skipped in production)
|
// Seed additional users from env (dev/test only, skipped in production)
|
||||||
handlers.SeedUsers(cfg, stores, uekCache)
|
handlers.SeedUsers(cfg, stores, uekCache)
|
||||||
@@ -184,6 +185,10 @@ func main() {
|
|||||||
handlers.InstallBundledPackages(cfg.BundledPackagesDir, bundledPkgDir, cfg.BundledPackages, stores, starlarkRunner)
|
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 ─────────────────
|
// ── Trigger Engine ─────────────────
|
||||||
triggerEngine := triggers.New(stores, starlarkRunner, bus)
|
triggerEngine := triggers.New(stores, starlarkRunner, bus)
|
||||||
if err := triggerEngine.Start(context.Background()); err != nil {
|
if err := triggerEngine.Start(context.Background()); err != nil {
|
||||||
@@ -342,7 +347,7 @@ func main() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// WebSocket endpoint
|
// 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) ──────────────
|
// ── Auth routes (rate limited) ──────────────
|
||||||
authMode, err := auth.ParseMode(cfg.AuthMode)
|
authMode, err := auth.ParseMode(cfg.AuthMode)
|
||||||
@@ -448,12 +453,15 @@ func main() {
|
|||||||
// Client SDK calls this on user interaction (debounced, max 1/min)
|
// Client SDK calls this on user interaction (debounced, max 1/min)
|
||||||
// to update last_activity_at for idle-timeout tracking.
|
// to update last_activity_at for idle-timeout tracking.
|
||||||
activityGroup := api.Group("/auth")
|
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)
|
activityGroup.POST("/activity", authH.Activity)
|
||||||
|
|
||||||
|
// ── Shared handler instances ──────────────
|
||||||
|
tokenH := handlers.NewAPITokenHandler(stores)
|
||||||
|
|
||||||
// ── Protected routes ────────────────────
|
// ── Protected routes ────────────────────
|
||||||
protected := api.Group("")
|
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())
|
protected.Use(middleware.ValidatePathParams())
|
||||||
{
|
{
|
||||||
// ── WebSocket Ticket ───────────
|
// ── WebSocket Ticket ───────────
|
||||||
@@ -559,6 +567,11 @@ func main() {
|
|||||||
permH := handlers.NewProfilePermissionsHandler(stores)
|
permH := handlers.NewProfilePermissionsHandler(stores)
|
||||||
protected.GET("/profile/permissions", permH.GetMyPermissions)
|
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
|
// Boot payload — single-call SDK bootstrap
|
||||||
bootH := handlers.NewProfileBootstrapHandler(stores)
|
bootH := handlers.NewProfileBootstrapHandler(stores)
|
||||||
protected.GET("/profile/bootstrap", bootH.GetBootstrap)
|
protected.GET("/profile/bootstrap", bootH.GetBootstrap)
|
||||||
@@ -680,7 +693,7 @@ func main() {
|
|||||||
|
|
||||||
// ── Admin routes ────────────────────────
|
// ── Admin routes ────────────────────────
|
||||||
admin := api.Group("/admin")
|
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.RequireAdmin(stores))
|
||||||
admin.Use(middleware.ValidatePathParams())
|
admin.Use(middleware.ValidatePathParams())
|
||||||
{
|
{
|
||||||
@@ -696,6 +709,9 @@ func main() {
|
|||||||
admin.POST("/users/:id/vault/reset", adm.ResetVault)
|
admin.POST("/users/:id/vault/reset", adm.ResetVault)
|
||||||
admin.DELETE("/users/:id", adm.DeleteUser)
|
admin.DELETE("/users/:id", adm.DeleteUser)
|
||||||
|
|
||||||
|
// Admin API Tokens (create tokens for any user)
|
||||||
|
admin.POST("/tokens", tokenH.AdminCreateToken)
|
||||||
|
|
||||||
// Global settings
|
// Global settings
|
||||||
admin.GET("/settings", adm.ListGlobalSettings)
|
admin.GET("/settings", adm.ListGlobalSettings)
|
||||||
admin.GET("/settings/:key", adm.GetGlobalSetting)
|
admin.GET("/settings/:key", adm.GetGlobalSetting)
|
||||||
@@ -910,7 +926,7 @@ func main() {
|
|||||||
{
|
{
|
||||||
extAPIH := handlers.NewExtAPIHandler(stores, starlarkRunner)
|
extAPIH := handlers.NewExtAPIHandler(stores, starlarkRunner)
|
||||||
extAPI := base.Group("/s/:slug/api")
|
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)
|
extAPI.Any("/*path", extAPIH.Handle)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -11,6 +12,7 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
|
||||||
|
"armature/auth"
|
||||||
"armature/config"
|
"armature/config"
|
||||||
"armature/database"
|
"armature/database"
|
||||||
"armature/store"
|
"armature/store"
|
||||||
@@ -184,9 +186,15 @@ func UserIDFromCookie(c *gin.Context, jwtSecret string) string {
|
|||||||
|
|
||||||
// ─── Auth middleware ─────────────────────────────────────────
|
// ─── Auth middleware ─────────────────────────────────────────
|
||||||
|
|
||||||
// Auth returns a Gin middleware that validates JWT bearer tokens and
|
// Auth returns a Gin middleware that validates JWT bearer tokens or personal
|
||||||
// verifies the user is active with their current DB role.
|
// access tokens (PATs) and verifies the user is active.
|
||||||
func Auth(cfg *config.Config, users store.UserStore, cache *UserStatusCache) gin.HandlerFunc {
|
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) {
|
return func(c *gin.Context) {
|
||||||
// Skip auth when running without a database (unmanaged mode)
|
// Skip auth when running without a database (unmanaged mode)
|
||||||
if !database.IsConnected() {
|
if !database.IsConnected() {
|
||||||
@@ -217,6 +225,13 @@ func Auth(cfg *config.Config, users store.UserStore, cache *UserStatusCache) gin
|
|||||||
return
|
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)
|
claims, ok := parseAndValidateJWT(tokenString, cfg.JWTSecret)
|
||||||
if !ok {
|
if !ok {
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
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 middleware ─────────────────────────────────────────
|
||||||
|
|
||||||
// CORS returns a middleware that sets cross-origin headers.
|
// 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
|
// When ?token= is used, a deprecation notice is logged. The ticket
|
||||||
// path avoids exposing the JWT in server logs, proxy logs, and
|
// path avoids exposing the JWT in server logs, proxy logs, and
|
||||||
// browser history.
|
// 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) {
|
return func(c *gin.Context) {
|
||||||
if !database.IsConnected() {
|
if !database.IsConnected() {
|
||||||
c.Next()
|
c.Next()
|
||||||
@@ -389,6 +431,12 @@ func WsAuth(cfg *config.Config, users store.UserStore, cache *UserStatusCache, t
|
|||||||
return
|
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)
|
claims, ok := parseAndValidateJWT(tokenString, cfg.JWTSecret)
|
||||||
if !ok {
|
if !ok {
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||||
|
|||||||
@@ -30,10 +30,26 @@ func RequirePermission(perm string, stores store.Stores) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// resolveAndCachePerms loads the user's effective permissions once per request.
|
// 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) {
|
func resolveAndCachePerms(c *gin.Context, stores store.Stores, userID string) (map[string]bool, error) {
|
||||||
if cached, exists := c.Get(permCacheKey); exists {
|
if cached, exists := c.Get(permCacheKey); exists {
|
||||||
return cached.(map[string]bool), nil
|
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)
|
perms, err := auth.ResolvePermissions(c.Request.Context(), stores, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
49
server/models/models_api_token.go
Normal file
49
server/models/models_api_token.go
Normal file
@@ -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"`
|
||||||
|
}
|
||||||
44
server/sandbox/permissions_module.go
Normal file
44
server/sandbox/permissions_module.go
Normal file
@@ -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
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -396,6 +396,9 @@ func (r *Runner) buildModulesWithLibCtx(ctx context.Context, packageID string, m
|
|||||||
}
|
}
|
||||||
modules["settings"] = BuildSettingsModule(ctx, r.stores, packageID, userID, teamID)
|
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.
|
// Allows any starlark package to load declared library dependencies.
|
||||||
if lc != nil {
|
if lc != nil {
|
||||||
modules["lib"] = BuildLibModule(ctx, r, packageID, rc, lc)
|
modules["lib"] = BuildLibModule(ctx, r, packageID, rc, lc)
|
||||||
|
|||||||
34
server/store/api_token_iface.go
Normal file
34
server/store/api_token_iface.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -62,6 +62,7 @@ type Stores struct {
|
|||||||
Triggers TriggerStore
|
Triggers TriggerStore
|
||||||
ScheduledTasks ScheduledTaskStore
|
ScheduledTasks ScheduledTaskStore
|
||||||
Cluster ClusterStore
|
Cluster ClusterStore
|
||||||
|
APITokens APITokenStore
|
||||||
}
|
}
|
||||||
|
|
||||||
// TeamAvailableModel is returned by CatalogStore.ListTeamAvailable.
|
// TeamAvailableModel is returned by CatalogStore.ListTeamAvailable.
|
||||||
|
|||||||
125
server/store/postgres/api_tokens.go
Normal file
125
server/store/postgres/api_tokens.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -33,5 +33,6 @@ func NewStores(db *sql.DB) store.Stores {
|
|||||||
Triggers: NewTriggerStore(),
|
Triggers: NewTriggerStore(),
|
||||||
ScheduledTasks: NewScheduledTaskStore(),
|
ScheduledTasks: NewScheduledTaskStore(),
|
||||||
Cluster: NewClusterStore(),
|
Cluster: NewClusterStore(),
|
||||||
|
APITokens: NewAPITokenStore(db),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
172
server/store/sqlite/api_tokens.go
Normal file
172
server/store/sqlite/api_tokens.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -32,5 +32,6 @@ func NewStores(db *sql.DB) store.Stores {
|
|||||||
RateLimits: NewRateLimitStore(),
|
RateLimits: NewRateLimitStore(),
|
||||||
Triggers: NewTriggerStore(),
|
Triggers: NewTriggerStore(),
|
||||||
ScheduledTasks: NewScheduledTaskStore(),
|
ScheduledTasks: NewScheduledTaskStore(),
|
||||||
|
APITokens: NewAPITokenStore(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export default function GroupsSection() {
|
|||||||
const [detail, setDetail] = useState(null);
|
const [detail, setDetail] = useState(null);
|
||||||
const [members, setMembers] = useState([]);
|
const [members, setMembers] = useState([]);
|
||||||
const [allPerms, setAllPerms] = useState([]);
|
const [allPerms, setAllPerms] = useState([]);
|
||||||
|
const [groupedPerms, setGroupedPerms] = useState({});
|
||||||
const [allUsers, setAllUsers] = useState([]);
|
const [allUsers, setAllUsers] = useState([]);
|
||||||
const [groupData, setGroupData] = useState({});
|
const [groupData, setGroupData] = useState({});
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
@@ -41,6 +42,7 @@ export default function GroupsSection() {
|
|||||||
]);
|
]);
|
||||||
setMembers(m || []);
|
setMembers(m || []);
|
||||||
setAllPerms(p.permissions || []);
|
setAllPerms(p.permissions || []);
|
||||||
|
setGroupedPerms(p.grouped || {});
|
||||||
setAllUsers(u || []);
|
setAllUsers(u || []);
|
||||||
} catch (e) { sw.toast(e.message, 'error'); }
|
} catch (e) { sw.toast(e.message, 'error'); }
|
||||||
}
|
}
|
||||||
@@ -131,6 +133,25 @@ export default function GroupsSection() {
|
|||||||
|
|
||||||
<div class="settings-section" style="margin-bottom:16px;">
|
<div class="settings-section" style="margin-bottom:16px;">
|
||||||
<h5 style="margin:0 0 8px;">Permissions</h5>
|
<h5 style="margin:0 0 8px;">Permissions</h5>
|
||||||
|
${Object.keys(groupedPerms).length > 0
|
||||||
|
? Object.entries(groupedPerms).map(([source, perms]) => html`
|
||||||
|
<div key=${source} style="margin-bottom:var(--sp-3,12px);">
|
||||||
|
<h6 style="margin:0 0 4px;font-size:0.85rem;color:var(--text-2);">
|
||||||
|
${source === 'kernel' ? 'Platform' : source}
|
||||||
|
</h6>
|
||||||
|
<div class="admin-permissions-grid">
|
||||||
|
${perms.map(p => html`
|
||||||
|
<label class="toggle-label" key=${p}>
|
||||||
|
<input type="checkbox" checked=${groupData.permissions.includes(p)}
|
||||||
|
onChange=${() => togglePerm(p)} />
|
||||||
|
<span class="toggle-track"></span>
|
||||||
|
<span>${p}</span>
|
||||||
|
</label>
|
||||||
|
`)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`)
|
||||||
|
: html`
|
||||||
<div class="admin-permissions-grid">
|
<div class="admin-permissions-grid">
|
||||||
${allPerms.map(p => html`
|
${allPerms.map(p => html`
|
||||||
<label class="toggle-label" key=${p}>
|
<label class="toggle-label" key=${p}>
|
||||||
@@ -141,6 +162,8 @@ export default function GroupsSection() {
|
|||||||
</label>
|
</label>
|
||||||
`)}
|
`)}
|
||||||
</div>
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="margin-bottom:16px;">
|
<div style="margin-bottom:16px;">
|
||||||
|
|||||||
@@ -87,6 +87,17 @@ export default function UsersSection() {
|
|||||||
} catch (e) { sw.toast(e.message, 'error'); }
|
} 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.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}`);
|
||||||
|
} catch (e) { sw.toast(e.message || 'Failed to create token', 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
function statusBadge(u) {
|
function statusBadge(u) {
|
||||||
if (u.status === 'pending') return html`<span class="badge badge-pending">pending</span>`;
|
if (u.status === 'pending') return html`<span class="badge badge-pending">pending</span>`;
|
||||||
if (!u.is_active) return html`<span class="badge badge-inactive">inactive</span>`;
|
if (!u.is_active) return html`<span class="badge badge-inactive">inactive</span>`;
|
||||||
@@ -142,6 +153,7 @@ export default function UsersSection() {
|
|||||||
: html`<button class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${() => toggleActive(u.id, true)}>Enable</button>`
|
: html`<button class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${() => toggleActive(u.id, true)}>Enable</button>`
|
||||||
}
|
}
|
||||||
<button class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${() => resetPassword(u.id, u.username)}>Reset PW</button>
|
<button class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${() => resetPassword(u.id, u.username)}>Reset PW</button>
|
||||||
|
<button class="sw-btn sw-btn--secondary sw-btn--sm" onClick=${() => createTokenForUser(u.id, u.username)}>PAT</button>
|
||||||
<button class="sw-btn sw-btn--danger sw-btn--sm" onClick=${() => deleteUser(u.id, u.username)}>Delete</button>
|
<button class="sw-btn sw-btn--danger sw-btn--sm" onClick=${() => deleteUser(u.id, u.username)}>Delete</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ const sectionModules = {
|
|||||||
teams: () => import('./teams.js'),
|
teams: () => import('./teams.js'),
|
||||||
connections: () => import('./connections.js'),
|
connections: () => import('./connections.js'),
|
||||||
notifications: () => import('./notifications.js'),
|
notifications: () => import('./notifications.js'),
|
||||||
|
tokens: () => import('./tokens.js'),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Nav structure ───────────────────────────
|
// ── Nav structure ───────────────────────────
|
||||||
@@ -35,6 +36,7 @@ const NAV_ITEMS = [
|
|||||||
{ key: 'teams', label: 'Teams' },
|
{ key: 'teams', label: 'Teams' },
|
||||||
{ key: 'connections', label: 'Connections' },
|
{ key: 'connections', label: 'Connections' },
|
||||||
{ key: 'notifications', label: 'Notifications' },
|
{ key: 'notifications', label: 'Notifications' },
|
||||||
|
{ key: 'tokens', label: 'API Tokens' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ── Section title map ───────────────────────
|
// ── Section title map ───────────────────────
|
||||||
@@ -42,6 +44,7 @@ const SECTION_TITLES = {
|
|||||||
general: 'General', appearance: 'Appearance',
|
general: 'General', appearance: 'Appearance',
|
||||||
profile: 'Profile', teams: 'Teams',
|
profile: 'Profile', teams: 'Teams',
|
||||||
connections: 'Connections', notifications: 'Notifications',
|
connections: 'Connections', notifications: 'Notifications',
|
||||||
|
tokens: 'API Tokens',
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Extension config sections ──────
|
// ── Extension config sections ──────
|
||||||
|
|||||||
256
src/js/sw/surfaces/settings/tokens.js
Normal file
256
src/js/sw/surfaces/settings/tokens.js
Normal file
@@ -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.get('/api/v1/auth/tokens');
|
||||||
|
setTokens(resp || []);
|
||||||
|
} catch (e) {
|
||||||
|
setTokens([]);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadPerms = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const resp = await sw.api.get('/api/v1/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.post('/api/v1/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.del(`/api/v1/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`<p>Loading…</p>`;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="ext-settings-tokens">
|
||||||
|
${createdToken && html`
|
||||||
|
<div class="ext-settings-tokens__created">
|
||||||
|
<strong>Token created — copy it now. It won't be shown again.</strong>
|
||||||
|
<div class="ext-settings-tokens__token-display">
|
||||||
|
<code class="ext-settings-tokens__token-value">${createdToken}</code>
|
||||||
|
<button class="sw-btn sw-btn--sm" onClick=${copyToken}>
|
||||||
|
${copied ? 'Copied!' : 'Copy'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<button class="sw-btn sw-btn--text sw-btn--sm" onClick=${dismissToken}>Dismiss</button>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
|
||||||
|
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:var(--sp-4);">
|
||||||
|
<p style="color:var(--text-2);margin:0;">
|
||||||
|
Personal access tokens provide programmatic API access.
|
||||||
|
Treat tokens like passwords.
|
||||||
|
</p>
|
||||||
|
<button class="sw-btn sw-btn--primary sw-btn--sm" onClick=${openCreate}>
|
||||||
|
New Token
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${showForm && html`
|
||||||
|
<div class="ext-settings-tokens__form" style="border:1px solid var(--border);border-radius:var(--radius);padding:var(--sp-4);margin-bottom:var(--sp-4);">
|
||||||
|
<div style="margin-bottom:var(--sp-3);">
|
||||||
|
<label style="display:block;font-weight:600;margin-bottom:var(--sp-1);">Name</label>
|
||||||
|
<input type="text" class="sw-input" placeholder="e.g. CI Pipeline"
|
||||||
|
value=${form.name}
|
||||||
|
onInput=${e => setForm(f => ({ ...f, name: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom:var(--sp-3);">
|
||||||
|
<label style="display:block;font-weight:600;margin-bottom:var(--sp-1);">Expiration (optional)</label>
|
||||||
|
<input type="date" class="sw-input"
|
||||||
|
value=${form.expires_at}
|
||||||
|
onInput=${e => setForm(f => ({ ...f, expires_at: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom:var(--sp-3);">
|
||||||
|
<label style="display:block;font-weight:600;margin-bottom:var(--sp-1);">Permissions</label>
|
||||||
|
<p style="color:var(--text-3);font-size:0.85rem;margin:0 0 var(--sp-2) 0;">
|
||||||
|
Select the permissions this token should have. Leave empty for read-only access.
|
||||||
|
</p>
|
||||||
|
<div style="display:flex;flex-wrap:wrap;gap:var(--sp-2);">
|
||||||
|
${availablePerms.map(p => html`
|
||||||
|
<label key=${p} style="display:flex;align-items:center;gap:var(--sp-1);cursor:pointer;">
|
||||||
|
<input type="checkbox"
|
||||||
|
checked=${form.permissions.includes(p)}
|
||||||
|
onChange=${() => togglePerm(p)} />
|
||||||
|
<code style="font-size:0.85rem;">${p}</code>
|
||||||
|
</label>
|
||||||
|
`)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex;gap:var(--sp-2);">
|
||||||
|
<button class="sw-btn sw-btn--primary sw-btn--sm" onClick=${create} disabled=${saving}>
|
||||||
|
${saving ? 'Creating…' : 'Create Token'}
|
||||||
|
</button>
|
||||||
|
<button class="sw-btn sw-btn--sm" onClick=${cancel}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
|
||||||
|
${tokens.length === 0 && !showForm && html`
|
||||||
|
<div class="sw-inline-error" style="text-align:center;padding:var(--sp-8) 0;">
|
||||||
|
<p>No tokens yet</p>
|
||||||
|
<p style="color:var(--text-3);">Create a personal access token for CI, scripts, or API integration.</p>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
|
||||||
|
${tokens.length > 0 && html`
|
||||||
|
<table class="sw-table" style="width:100%;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Token</th>
|
||||||
|
<th>Permissions</th>
|
||||||
|
<th>Expires</th>
|
||||||
|
<th>Last Used</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
${tokens.map(t => html`
|
||||||
|
<tr key=${t.id}>
|
||||||
|
<td><strong>${esc(t.name)}</strong></td>
|
||||||
|
<td><code style="font-size:0.85rem;color:var(--text-3);">arm_pat_${esc(t.prefix)}…</code></td>
|
||||||
|
<td>
|
||||||
|
${(t.permissions || []).length === 0
|
||||||
|
? html`<span style="color:var(--text-3);">none</span>`
|
||||||
|
: (t.permissions || []).map(p => html`
|
||||||
|
<code key=${p} class="ext-settings-tokens__perm-badge">${p}</code>
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>${t.expires_at ? new Date(t.expires_at).toLocaleDateString() : '—'}</td>
|
||||||
|
<td>${t.last_used_at ? new Date(t.last_used_at).toLocaleDateString() : 'Never'}</td>
|
||||||
|
<td>
|
||||||
|
<button class="sw-btn sw-btn--danger sw-btn--sm"
|
||||||
|
onClick=${() => revoke(t.id, t.name)}>
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.ext-settings-tokens__created {
|
||||||
|
background: var(--bg-success, #e8f5e9);
|
||||||
|
border: 1px solid var(--success, #4caf50);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: var(--sp-4);
|
||||||
|
margin-bottom: var(--sp-4);
|
||||||
|
}
|
||||||
|
.ext-settings-tokens__token-display {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--sp-2);
|
||||||
|
margin: var(--sp-2) 0;
|
||||||
|
}
|
||||||
|
.ext-settings-tokens__token-value {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
word-break: break-all;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: var(--sp-2) var(--sp-3);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.ext-settings-tokens__perm-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
margin: 1px 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default TokensSection;
|
||||||
Reference in New Issue
Block a user