Feat v0.7.7 api tokens ext permissions #61
36
CHANGELOG.md
36
CHANGELOG.md
@@ -2,6 +2,42 @@
|
||||
|
||||
All notable changes to Armature are documented here.
|
||||
|
||||
## v0.7.7 — API Tokens + Extension Permissions
|
||||
|
||||
Personal access tokens (PATs) for programmatic API access, plus extension-declared
|
||||
user permissions for backend RBAC enforcement.
|
||||
|
||||
**API Tokens (PATs)**
|
||||
|
||||
- Migration 015: `api_tokens` table (PG + SQLite) with SHA-256 hash, prefix, JSON permissions, expiry
|
||||
- Token store interface + PG/SQLite implementations (Create, GetByHash, ListForUser, Revoke, CleanExpired, UpdateLastUsed)
|
||||
- `POST /api/v1/auth/tokens` — create token (returns plaintext once), permissions validated as subset of user's
|
||||
- `GET /api/v1/auth/tokens` — list my tokens; `DELETE /api/v1/auth/tokens/:id` — revoke
|
||||
- `POST /api/v1/admin/tokens` — create token for any user (audit logged as `admin.token.create`)
|
||||
- Auth middleware: `Bearer arm_pat_...` tokens validated alongside JWTs, user active check, fire-and-forget `last_used_at` update
|
||||
- Permission scoping: PAT permissions used directly at request time (git model — retained until revoked)
|
||||
- `auth.HashToken()` shared SHA-256 utility (replaces local `hashToken()` in auth.go)
|
||||
- Settings UI: API Tokens tab with create form, permission checkboxes, copy-once display, revoke button
|
||||
- Admin UI: "PAT" button on user rows creates tokens for any user
|
||||
- `BootstrapPAT`: `ARMATURE_BOOTSTRAP_PAT=true` env var creates admin PAT at startup, writes to `/tmp/armature-admin-pat.txt`
|
||||
- E2E smoke test: reads bootstrap PAT before falling back to login flow
|
||||
|
||||
**Extension-Declared User Permissions**
|
||||
|
||||
- Dynamic permission registry: `RegisterExtensionPermissions()` / `UnregisterExtensionPermissions()` with RWMutex
|
||||
- `AllPermissionsWithExtensions()` returns kernel + extension permissions; `AllPermissionsGrouped()` for admin UI
|
||||
- `user_permissions` manifest field: extensions declare user-facing permissions
|
||||
- `gate_permission` manifest field: ext_api.go checks user permission before calling `on_request`
|
||||
- `req["permissions"]` in Starlark request dict: user's effective permissions included for inline checks
|
||||
- `permissions.check(user_id, perm)` Starlark module: read-only permission check, always available (no sandbox gate)
|
||||
- Group UI: permissions grouped by source (Platform / package name) with section headings
|
||||
- Boot-time scan: `RegisterAllExtensionUserPermissions()` populates registry from active packages
|
||||
- Uninstall cleanup: `UnregisterExtensionPermissions()` called on package delete
|
||||
|
||||
**Tests:** 10 new tests (7 handler + 3 auth registry)
|
||||
|
||||
---
|
||||
|
||||
## v0.7.6 — Code Hygiene + Test Coverage
|
||||
|
||||
**Critical Fixes**
|
||||
|
||||
34
ROADMAP.md
34
ROADMAP.md
@@ -1,6 +1,6 @@
|
||||
# Armature — Roadmap
|
||||
|
||||
## Current: v0.7.6 — Code Hygiene + Test Coverage
|
||||
## Current: v0.7.7 — API Tokens + Extension Permissions
|
||||
|
||||
Self-hosted extensible platform kernel. Auth, identity, packages, Starlark
|
||||
sandbox, storage, realtime, and ops are kernel primitives. Everything else
|
||||
@@ -246,27 +246,27 @@ sidecar auth (v0.10.x).
|
||||
|
||||
| Step | Status | Description |
|
||||
|------|--------|-------------|
|
||||
| `api_tokens` table + migration | | `id`, `user_id`, `name`, `token_hash`, `permissions` (JSON array), `expires_at`, `last_used_at`, `created_at`. PG + SQLite. |
|
||||
| Token store interface | | `Create`, `GetByHash`, `ListForUser`, `Revoke`, `CleanExpired`, `UpdateLastUsed`. |
|
||||
| Token generation endpoint | | `POST /api/v1/auth/tokens` — name, permissions (subset of user's), expires_at. Returns token once (plain text), stored as SHA-256 hash. |
|
||||
| Token management endpoints | | `GET /api/v1/auth/tokens` (list mine), `DELETE /api/v1/auth/tokens/:id` (revoke). |
|
||||
| Admin token endpoint | | `POST /api/v1/admin/tokens` — creates token for any user. Explicit admin action, audit logged. |
|
||||
| Auth middleware update | | Accept `Bearer arm_pat_...` tokens alongside JWTs. Resolve user + scoped permissions from token. PAT requests skip refresh token flow. |
|
||||
| Permission scoping | | Token permissions ⊆ creating user's resolved permissions at creation time. Validated on create. If user loses a permission later, token retains it until revoked (git model). |
|
||||
| Settings UI | | Settings → API Tokens tab. Create, list, revoke. Show permissions, expiry, last used. Token value shown once on creation. |
|
||||
| Admin UI | | Admin → People → user detail → "Create API Token" action. |
|
||||
| E2E test migration | | Replace `ci/e2e-smoke-test.sh` login flow with PAT-based auth. Seed token in docker-compose bootstrap. |
|
||||
| `api_tokens` table + migration | done | `id`, `user_id`, `name`, `token_hash`, `permissions` (JSON array), `expires_at`, `last_used_at`, `created_at`. PG + SQLite. |
|
||||
| Token store interface | done | `Create`, `GetByHash`, `ListForUser`, `Revoke`, `CleanExpired`, `UpdateLastUsed`. |
|
||||
| Token generation endpoint | done | `POST /api/v1/auth/tokens` — name, permissions (subset of user's), expires_at. Returns token once (plain text), stored as SHA-256 hash. |
|
||||
| Token management endpoints | done | `GET /api/v1/auth/tokens` (list mine), `DELETE /api/v1/auth/tokens/:id` (revoke). |
|
||||
| Admin token endpoint | done | `POST /api/v1/admin/tokens` — creates token for any user. Explicit admin action, audit logged. |
|
||||
| Auth middleware update | done | Accept `Bearer arm_pat_...` tokens alongside JWTs. Resolve user + scoped permissions from token. PAT requests skip refresh token flow. |
|
||||
| Permission scoping | done | Token permissions ⊆ creating user's resolved permissions at creation time. Validated on create. If user loses a permission later, token retains it until revoked (git model). |
|
||||
| Settings UI | done | Settings → API Tokens tab. Create, list, revoke. Show permissions, expiry, last used. Token value shown once on creation. |
|
||||
| Admin UI | done | Admin → People → user detail → "Create API Token" action. |
|
||||
| E2E test migration | done | Replace `ci/e2e-smoke-test.sh` login flow with PAT-based auth. Seed token via `ARMATURE_BOOTSTRAP_PAT=true`. |
|
||||
|
||||
**Extension-Declared User Permissions**
|
||||
|
||||
| Step | Status | Description |
|
||||
|------|--------|-------------|
|
||||
| `user_permissions` manifest field | | Extensions declare permissions users need: `"user_permissions": ["image-gen.use", "image-gen.admin"]`. |
|
||||
| Dynamic permission registry | | On install: merge into valid permission set. On uninstall: remove. `AllPermissions` becomes `KernelPermissions + ExtensionPermissions`. |
|
||||
| Group UI update | | Admin → Groups → permission checkboxes include extension-registered permissions, grouped by source package. |
|
||||
| `permissions` Starlark module | | `permissions.check(user_id, "image-gen.use")` → resolves user's groups, returns bool. New permission: none required (read-only check against kernel data). |
|
||||
| `gate_permission` manifest field | | Optional. If set, `ext_api.go` checks this permission before calling `on_request`. Extension doesn't execute for unauthorized users. |
|
||||
| `req["permissions"]` in request dict | | `ext_api.go` resolves user permissions and includes them in the request dict. Extensions can check inline without the module. |
|
||||
| `user_permissions` manifest field | done | Extensions declare permissions users need: `"user_permissions": ["image-gen.use", "image-gen.admin"]`. |
|
||||
| Dynamic permission registry | done | On install: merge into valid permission set. On uninstall: remove. `AllPermissions` becomes `KernelPermissions + ExtensionPermissions`. |
|
||||
| Group UI update | done | Admin → Groups → permission checkboxes include extension-registered permissions, grouped by source package. |
|
||||
| `permissions` Starlark module | done | `permissions.check(user_id, "image-gen.use")` → resolves user's groups, returns bool. New permission: none required (read-only check against kernel data). |
|
||||
| `gate_permission` manifest field | done | Optional. If set, `ext_api.go` checks this permission before calling `on_request`. Extension doesn't execute for unauthorized users. |
|
||||
| `req["permissions"]` in request dict | done | `ext_api.go` resolves user permissions and includes them in the request dict. Extensions can check inline without the module. |
|
||||
|
||||
### v0.7.8 — Deferred Test Coverage
|
||||
|
||||
|
||||
@@ -32,11 +32,22 @@ echo " Server: ${SERVER_URL}"
|
||||
mkdir -p "${SCREENSHOT_DIR}"
|
||||
|
||||
# ── Authenticate ─────────────────────────────
|
||||
echo -e "${YELLOW}Authenticating...${NC}"
|
||||
TOKEN=$(curl -sf -X POST "${SERVER_URL}/api/v1/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"login\":\"${ADMIN_USER}\",\"password\":\"${ADMIN_PASS}\"}" \
|
||||
| node -e "process.stdin.resume(); let d=''; process.stdin.on('data',c=>d+=c); process.stdin.on('end',()=>{try{console.log(JSON.parse(d).token)}catch(e){process.exit(1)}})")
|
||||
# Try PAT first (from bootstrap), fall back to login
|
||||
PAT_FILE="/tmp/armature-admin-pat.txt"
|
||||
TOKEN=""
|
||||
|
||||
if [ -f "$PAT_FILE" ]; then
|
||||
TOKEN=$(cat "$PAT_FILE")
|
||||
echo -e "${GREEN}Authenticated via bootstrap PAT${NC}"
|
||||
fi
|
||||
|
||||
if [ -z "$TOKEN" ]; then
|
||||
echo -e "${YELLOW}Authenticating via login...${NC}"
|
||||
TOKEN=$(curl -sf -X POST "${SERVER_URL}/api/v1/auth/login" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"login\":\"${ADMIN_USER}\",\"password\":\"${ADMIN_PASS}\"}" \
|
||||
| node -e "process.stdin.resume(); let d=''; process.stdin.on('data',c=>d+=c); process.stdin.on('end',()=>{try{console.log(JSON.parse(d).token)}catch(e){process.exit(1)}})")
|
||||
fi
|
||||
|
||||
if [ -z "$TOKEN" ]; then
|
||||
echo -e "${RED}Failed to authenticate${NC}"
|
||||
|
||||
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"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
"armature/database"
|
||||
"armature/store"
|
||||
@@ -30,9 +31,8 @@ const (
|
||||
PermTokenUnlimited = "token.unlimited" // bypass token budgets
|
||||
)
|
||||
|
||||
// AllPermissions is the complete set of valid permission strings.
|
||||
// Used for validation in handlers and rendering checkboxes in admin UI.
|
||||
var AllPermissions = []string{
|
||||
// KernelPermissions is the static set of platform permission strings.
|
||||
var KernelPermissions = []string{
|
||||
PermSurfaceAdminAccess,
|
||||
PermExtensionUse,
|
||||
PermExtensionInstall,
|
||||
@@ -42,6 +42,63 @@ var AllPermissions = []string{
|
||||
PermTokenUnlimited,
|
||||
}
|
||||
|
||||
// AllPermissions is the kernel permissions. For the complete set including
|
||||
// extension-declared permissions, use AllPermissionsWithExtensions().
|
||||
// Kept as a var for backward compatibility with EnsureAdminsGroup and
|
||||
// other call sites that only need kernel permissions.
|
||||
var AllPermissions = KernelPermissions
|
||||
|
||||
// ── Extension Permission Registry ────────────
|
||||
|
||||
var (
|
||||
extPermsMu sync.RWMutex
|
||||
extPerms = make(map[string][]string) // packageID → declared user permissions
|
||||
)
|
||||
|
||||
// RegisterExtensionPermissions registers user-facing permissions declared by
|
||||
// an extension package. Called on install and at boot for active packages.
|
||||
func RegisterExtensionPermissions(packageID string, perms []string) {
|
||||
extPermsMu.Lock()
|
||||
extPerms[packageID] = perms
|
||||
extPermsMu.Unlock()
|
||||
}
|
||||
|
||||
// UnregisterExtensionPermissions removes user-facing permissions declared by
|
||||
// an extension package. Called on uninstall.
|
||||
func UnregisterExtensionPermissions(packageID string) {
|
||||
extPermsMu.Lock()
|
||||
delete(extPerms, packageID)
|
||||
extPermsMu.Unlock()
|
||||
}
|
||||
|
||||
// AllPermissionsWithExtensions returns kernel + extension-declared permissions.
|
||||
func AllPermissionsWithExtensions() []string {
|
||||
extPermsMu.RLock()
|
||||
defer extPermsMu.RUnlock()
|
||||
|
||||
result := make([]string, len(KernelPermissions))
|
||||
copy(result, KernelPermissions)
|
||||
for _, perms := range extPerms {
|
||||
result = append(result, perms...)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// AllPermissionsGrouped returns permissions grouped by source.
|
||||
// "kernel" key holds platform permissions; other keys are package IDs.
|
||||
func AllPermissionsGrouped() map[string][]string {
|
||||
extPermsMu.RLock()
|
||||
defer extPermsMu.RUnlock()
|
||||
|
||||
result := map[string][]string{
|
||||
"kernel": KernelPermissions,
|
||||
}
|
||||
for pkgID, perms := range extPerms {
|
||||
result[pkgID] = perms
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ── Resolution ──────────────────────────────
|
||||
|
||||
// ResolvePermissions returns the effective permission set for a user.
|
||||
|
||||
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 (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -384,8 +383,7 @@ func (h *AuthHandler) generateTokens(user *models.User, keepLogin bool) (gin.H,
|
||||
}
|
||||
|
||||
func hashToken(token string) string {
|
||||
h := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(h[:])
|
||||
return auth.HashToken(token)
|
||||
}
|
||||
|
||||
// ── Vault Lifecycle ─────────────────────────
|
||||
@@ -582,6 +580,60 @@ func BootstrapAdmin(cfg *config.Config, s store.Stores, uekCache ...*crypto.UEKC
|
||||
log.Printf(" ✅ Admin user '%s' created", cfg.AdminUsername)
|
||||
}
|
||||
|
||||
// BootstrapPAT creates a personal access token for the admin user when
|
||||
// ARMATURE_BOOTSTRAP_PAT=true. Writes the token to /tmp/armature-admin-pat.txt
|
||||
// for use by CI scripts. The token has all permissions and no expiry.
|
||||
func BootstrapPAT(cfg *config.Config, s store.Stores) {
|
||||
if os.Getenv("ARMATURE_BOOTSTRAP_PAT") != "true" {
|
||||
return
|
||||
}
|
||||
if cfg.AdminUsername == "" || s.APITokens == nil {
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
admin, err := s.Users.GetByUsername(ctx, cfg.AdminUsername)
|
||||
if err != nil || admin == nil {
|
||||
log.Printf("⚠ BootstrapPAT: admin user not found")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if a bootstrap token already exists
|
||||
existing, _ := s.APITokens.ListForUser(ctx, admin.ID)
|
||||
for _, t := range existing {
|
||||
if t.Name == "bootstrap-ci" {
|
||||
log.Printf(" ℹ Bootstrap PAT already exists (prefix: %s)", t.Prefix)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Generate and store
|
||||
raw, tokenHash, prefix := generateToken()
|
||||
allPerms := auth.AllPermissions
|
||||
permsCopy := make([]string, len(allPerms))
|
||||
copy(permsCopy, allPerms)
|
||||
|
||||
token := &models.APIToken{
|
||||
UserID: admin.ID,
|
||||
Name: "bootstrap-ci",
|
||||
TokenHash: tokenHash,
|
||||
Prefix: prefix,
|
||||
Permissions: permsCopy,
|
||||
CreatedBy: &admin.ID,
|
||||
}
|
||||
if err := s.APITokens.Create(ctx, token); err != nil {
|
||||
log.Printf("⚠ BootstrapPAT: failed to create token: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Write token to file for CI consumption
|
||||
if err := os.WriteFile("/tmp/armature-admin-pat.txt", []byte(raw), 0600); err != nil {
|
||||
log.Printf("⚠ BootstrapPAT: failed to write token file: %v", err)
|
||||
// Still log it for docker-compose stdout capture
|
||||
}
|
||||
log.Printf(" ✅ Bootstrap PAT created (prefix: %s), written to /tmp/armature-admin-pat.txt", prefix)
|
||||
}
|
||||
|
||||
// SeedUsers creates or updates users from the SEED_USERS env var.
|
||||
// Format: "user:pass[:admin],user2:pass2" — third field "admin" adds to Admins group.
|
||||
// Upsert: existing users get their password refreshed on every restart.
|
||||
|
||||
@@ -41,6 +41,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.starlark.net/starlark"
|
||||
|
||||
"armature/auth"
|
||||
"armature/models"
|
||||
"armature/sandbox"
|
||||
"armature/store"
|
||||
@@ -125,8 +126,17 @@ func (h *ExtAPIHandler) Handle(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// ── 4b. Gate permission check ──────────────
|
||||
if gatePerm, _ := pkg.Manifest["gate_permission"].(string); gatePerm != "" {
|
||||
userPerms, err := auth.ResolvePermissions(c.Request.Context(), h.stores, getUserID(c))
|
||||
if err != nil || !userPerms[gatePerm] {
|
||||
c.JSON(http.StatusForbidden, gin.H{"error": "permission required: " + gatePerm})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// ── 5. Build request dict ──────────────────
|
||||
reqDict, err := buildRequestDict(c, rawPath)
|
||||
reqDict, err := buildRequestDict(c, rawPath, h.stores)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read request: " + err.Error()})
|
||||
return
|
||||
@@ -228,7 +238,7 @@ func matchAPIRoute(manifest map[string]any, method, path string) bool {
|
||||
// "body": "...",
|
||||
// "user_id": "uuid",
|
||||
// }
|
||||
func buildRequestDict(c *gin.Context, path string) (*starlark.Dict, error) {
|
||||
func buildRequestDict(c *gin.Context, path string, stores ...store.Stores) (*starlark.Dict, error) {
|
||||
// Read body (capped at 1 MB for safety)
|
||||
body, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20))
|
||||
if err != nil {
|
||||
@@ -251,13 +261,24 @@ func buildRequestDict(c *gin.Context, path string) (*starlark.Dict, error) {
|
||||
}
|
||||
}
|
||||
|
||||
d := starlark.NewDict(6)
|
||||
d := starlark.NewDict(7)
|
||||
_ = d.SetKey(starlark.String("method"), starlark.String(c.Request.Method))
|
||||
_ = d.SetKey(starlark.String("path"), starlark.String(path))
|
||||
_ = d.SetKey(starlark.String("headers"), hdrs)
|
||||
_ = d.SetKey(starlark.String("query"), query)
|
||||
_ = d.SetKey(starlark.String("body"), starlark.String(string(body)))
|
||||
_ = d.SetKey(starlark.String("user_id"), starlark.String(getUserID(c)))
|
||||
|
||||
// Resolve user permissions for extension inline checks
|
||||
if len(stores) > 0 {
|
||||
userPerms, _ := auth.ResolvePermissions(c.Request.Context(), stores[0], getUserID(c))
|
||||
permList := make([]starlark.Value, 0, len(userPerms))
|
||||
for p := range userPerms {
|
||||
permList = append(permList, starlark.String(p))
|
||||
}
|
||||
_ = d.SetKey(starlark.String("permissions"), starlark.NewList(permList))
|
||||
}
|
||||
|
||||
return d, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"armature/auth"
|
||||
"armature/models"
|
||||
"armature/store"
|
||||
)
|
||||
@@ -167,6 +170,28 @@ func (h *ExtPermHandler) maybeSuspend(c *gin.Context, pkgID string) {
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterAllExtensionUserPermissions scans all active packages and registers
|
||||
// their user_permissions in the dynamic permission registry. Called at boot.
|
||||
func RegisterAllExtensionUserPermissions(stores store.Stores) {
|
||||
ctx := context.Background()
|
||||
pkgs, err := stores.Packages.List(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
count := 0
|
||||
for _, pkg := range pkgs {
|
||||
if pkg.Manifest != nil {
|
||||
SyncUserPermissions(pkg.Manifest, pkg.ID)
|
||||
if _, ok := pkg.Manifest["user_permissions"]; ok {
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
if count > 0 {
|
||||
log.Printf(" ✅ Registered user permissions from %d package(s)", count)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Manifest Permission Parsing ──────────────
|
||||
|
||||
// SyncManifestPermissions extracts the "permissions" array from a
|
||||
@@ -208,5 +233,31 @@ func SyncManifestPermissions(c *gin.Context, stores store.Stores, pkgID string,
|
||||
// Package needs review before activation
|
||||
_ = stores.Packages.SetStatus(c.Request.Context(), pkgID, models.PackageStatusPendingReview)
|
||||
|
||||
// Register user-facing permissions in the dynamic permission registry
|
||||
SyncUserPermissions(manifest, pkgID)
|
||||
|
||||
return perms
|
||||
}
|
||||
|
||||
// SyncUserPermissions registers extension-declared user permissions in the
|
||||
// kernel's dynamic permission registry. Called on install and at boot.
|
||||
func SyncUserPermissions(manifest map[string]any, pkgID string) {
|
||||
raw, ok := manifest["user_permissions"]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
arr, ok := raw.([]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var userPerms []string
|
||||
for _, v := range arr {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
userPerms = append(userPerms, s)
|
||||
}
|
||||
}
|
||||
if len(userPerms) > 0 {
|
||||
auth.RegisterExtensionPermissions(pkgID, userPerms)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ func (h *GroupHandler) UpdateGroup(c *gin.Context) {
|
||||
|
||||
// Validate permissions if provided
|
||||
if patch.Permissions != nil {
|
||||
valid := auth.AllPermissions
|
||||
valid := auth.AllPermissionsWithExtensions()
|
||||
validSet := make(map[string]bool, len(valid))
|
||||
for _, p := range valid {
|
||||
validSet[p] = true
|
||||
@@ -433,7 +433,10 @@ func (h *GroupHandler) DeleteResourceGrant(c *gin.Context) {
|
||||
// ListPermissions returns all valid permission strings.
|
||||
// GET /api/v1/admin/permissions
|
||||
func (h *GroupHandler) ListPermissions(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"permissions": auth.AllPermissions})
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"permissions": auth.AllPermissionsWithExtensions(),
|
||||
"grouped": auth.AllPermissionsGrouped(),
|
||||
})
|
||||
}
|
||||
|
||||
// GetUserPermissions returns the effective permissions for a given user.
|
||||
|
||||
@@ -27,8 +27,10 @@ type ManifestInfo struct {
|
||||
HasSettings bool
|
||||
HasExports bool
|
||||
Dependencies map[string]any
|
||||
Requires []string
|
||||
Signature string // reserved for future package signing
|
||||
Requires []string
|
||||
Signature string // reserved for future package signing
|
||||
UserPermissions []string // user-facing permissions declared by the extension
|
||||
GatePermission string // if set, checks this user permission before calling on_request
|
||||
}
|
||||
|
||||
// ValidateManifest parses a manifest map and validates all required fields,
|
||||
@@ -97,6 +99,16 @@ func ValidateManifest(manifest map[string]any) (*ManifestInfo, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Extension-declared user permissions
|
||||
if ups, ok := manifest["user_permissions"].([]any); ok {
|
||||
for _, v := range ups {
|
||||
if s, ok := v.(string); ok && s != "" {
|
||||
info.UserPermissions = append(info.UserPermissions, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
info.GatePermission, _ = manifest["gate_permission"].(string)
|
||||
|
||||
info.SchemaVersion = ParseSchemaVersion(manifest)
|
||||
|
||||
// ── Type-specific constraints ────────────────────────────────
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.starlark.net/starlark"
|
||||
|
||||
"armature/auth"
|
||||
"armature/database"
|
||||
"armature/events"
|
||||
"armature/models"
|
||||
@@ -193,6 +194,9 @@ func (h *PackageHandler) DeletePackage(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Unregister user-facing permissions from the dynamic registry
|
||||
auth.UnregisterExtensionPermissions(id)
|
||||
|
||||
// Clean up extracted static assets
|
||||
if h.packagesDir != "" {
|
||||
assetDir := filepath.Join(h.packagesDir, id)
|
||||
|
||||
@@ -29,8 +29,9 @@ func (h *ProfilePermissionsHandler) GetMyPermissions(c *gin.Context) {
|
||||
// Admin gets all permissions by definition.
|
||||
var list []string
|
||||
if role == "admin" {
|
||||
list = make([]string, len(auth.AllPermissions))
|
||||
copy(list, auth.AllPermissions)
|
||||
all := auth.AllPermissionsWithExtensions()
|
||||
list = make([]string, len(all))
|
||||
copy(list, all)
|
||||
} else {
|
||||
perms, err := auth.ResolvePermissions(ctx, h.stores, userID)
|
||||
if err != nil {
|
||||
|
||||
@@ -115,6 +115,7 @@ func main() {
|
||||
|
||||
// Bootstrap admin from env (K8s secret) — upserts on every restart
|
||||
handlers.BootstrapAdmin(cfg, stores, uekCache)
|
||||
handlers.BootstrapPAT(cfg, stores)
|
||||
|
||||
// Seed additional users from env (dev/test only, skipped in production)
|
||||
handlers.SeedUsers(cfg, stores, uekCache)
|
||||
@@ -184,6 +185,10 @@ func main() {
|
||||
handlers.InstallBundledPackages(cfg.BundledPackagesDir, bundledPkgDir, cfg.BundledPackages, stores, starlarkRunner)
|
||||
}
|
||||
|
||||
// ── Register extension user permissions ─────
|
||||
// Scan active packages on boot and populate the dynamic permission registry.
|
||||
handlers.RegisterAllExtensionUserPermissions(stores)
|
||||
|
||||
// ── Trigger Engine ─────────────────
|
||||
triggerEngine := triggers.New(stores, starlarkRunner, bus)
|
||||
if err := triggerEngine.Start(context.Background()); err != nil {
|
||||
@@ -342,7 +347,7 @@ func main() {
|
||||
})
|
||||
|
||||
// WebSocket endpoint
|
||||
base.GET("/ws", middleware.WsAuth(cfg, stores.Users, userCache, ticketAdapter), hub.HandleWebSocket)
|
||||
base.GET("/ws", middleware.WsAuth(cfg, stores.Users, userCache, ticketAdapter, stores.APITokens), hub.HandleWebSocket)
|
||||
|
||||
// ── Auth routes (rate limited) ──────────────
|
||||
authMode, err := auth.ParseMode(cfg.AuthMode)
|
||||
@@ -448,12 +453,15 @@ func main() {
|
||||
// Client SDK calls this on user interaction (debounced, max 1/min)
|
||||
// to update last_activity_at for idle-timeout tracking.
|
||||
activityGroup := api.Group("/auth")
|
||||
activityGroup.Use(middleware.Auth(cfg, stores.Users, userCache))
|
||||
activityGroup.Use(middleware.Auth(cfg, stores.Users, userCache, stores.APITokens))
|
||||
activityGroup.POST("/activity", authH.Activity)
|
||||
|
||||
// ── Shared handler instances ──────────────
|
||||
tokenH := handlers.NewAPITokenHandler(stores)
|
||||
|
||||
// ── Protected routes ────────────────────
|
||||
protected := api.Group("")
|
||||
protected.Use(middleware.Auth(cfg, stores.Users, userCache))
|
||||
protected.Use(middleware.Auth(cfg, stores.Users, userCache, stores.APITokens))
|
||||
protected.Use(middleware.ValidatePathParams())
|
||||
{
|
||||
// ── WebSocket Ticket ───────────
|
||||
@@ -559,6 +567,11 @@ func main() {
|
||||
permH := handlers.NewProfilePermissionsHandler(stores)
|
||||
protected.GET("/profile/permissions", permH.GetMyPermissions)
|
||||
|
||||
// API Tokens (PATs)
|
||||
protected.POST("/auth/tokens", tokenH.CreateToken)
|
||||
protected.GET("/auth/tokens", tokenH.ListTokens)
|
||||
protected.DELETE("/auth/tokens/:id", tokenH.RevokeToken)
|
||||
|
||||
// Boot payload — single-call SDK bootstrap
|
||||
bootH := handlers.NewProfileBootstrapHandler(stores)
|
||||
protected.GET("/profile/bootstrap", bootH.GetBootstrap)
|
||||
@@ -680,7 +693,7 @@ func main() {
|
||||
|
||||
// ── Admin routes ────────────────────────
|
||||
admin := api.Group("/admin")
|
||||
admin.Use(middleware.Auth(cfg, stores.Users, userCache))
|
||||
admin.Use(middleware.Auth(cfg, stores.Users, userCache, stores.APITokens))
|
||||
admin.Use(middleware.RequireAdmin(stores))
|
||||
admin.Use(middleware.ValidatePathParams())
|
||||
{
|
||||
@@ -696,6 +709,9 @@ func main() {
|
||||
admin.POST("/users/:id/vault/reset", adm.ResetVault)
|
||||
admin.DELETE("/users/:id", adm.DeleteUser)
|
||||
|
||||
// Admin API Tokens (create tokens for any user)
|
||||
admin.POST("/tokens", tokenH.AdminCreateToken)
|
||||
|
||||
// Global settings
|
||||
admin.GET("/settings", adm.ListGlobalSettings)
|
||||
admin.GET("/settings/:key", adm.GetGlobalSetting)
|
||||
@@ -910,7 +926,7 @@ func main() {
|
||||
{
|
||||
extAPIH := handlers.NewExtAPIHandler(stores, starlarkRunner)
|
||||
extAPI := base.Group("/s/:slug/api")
|
||||
extAPI.Use(middleware.Auth(cfg, stores.Users, userCache))
|
||||
extAPI.Use(middleware.Auth(cfg, stores.Users, userCache, stores.APITokens))
|
||||
extAPI.Any("/*path", extAPIH.Handle)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"armature/auth"
|
||||
"armature/config"
|
||||
"armature/database"
|
||||
"armature/store"
|
||||
@@ -184,9 +186,15 @@ func UserIDFromCookie(c *gin.Context, jwtSecret string) string {
|
||||
|
||||
// ─── Auth middleware ─────────────────────────────────────────
|
||||
|
||||
// Auth returns a Gin middleware that validates JWT bearer tokens and
|
||||
// verifies the user is active with their current DB role.
|
||||
func Auth(cfg *config.Config, users store.UserStore, cache *UserStatusCache) gin.HandlerFunc {
|
||||
// Auth returns a Gin middleware that validates JWT bearer tokens or personal
|
||||
// access tokens (PATs) and verifies the user is active.
|
||||
func Auth(cfg *config.Config, users store.UserStore, cache *UserStatusCache, tokens ...store.APITokenStore) gin.HandlerFunc {
|
||||
// Optional PAT store — passed as variadic to keep call sites compatible.
|
||||
var tokenStore store.APITokenStore
|
||||
if len(tokens) > 0 && tokens[0] != nil {
|
||||
tokenStore = tokens[0]
|
||||
}
|
||||
|
||||
return func(c *gin.Context) {
|
||||
// Skip auth when running without a database (unmanaged mode)
|
||||
if !database.IsConnected() {
|
||||
@@ -217,6 +225,13 @@ func Auth(cfg *config.Config, users store.UserStore, cache *UserStatusCache) gin
|
||||
return
|
||||
}
|
||||
|
||||
// ── PAT path ──
|
||||
if strings.HasPrefix(tokenString, "arm_pat_") && tokenStore != nil {
|
||||
authenticatePAT(c, tokenString, tokenStore, users, cache)
|
||||
return
|
||||
}
|
||||
|
||||
// ── JWT path ──
|
||||
claims, ok := parseAndValidateJWT(tokenString, cfg.JWTSecret)
|
||||
if !ok {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||
@@ -236,6 +251,33 @@ func Auth(cfg *config.Config, users store.UserStore, cache *UserStatusCache) gin
|
||||
}
|
||||
}
|
||||
|
||||
// authenticatePAT validates a personal access token and sets context values.
|
||||
func authenticatePAT(c *gin.Context, tokenString string, tokenStore store.APITokenStore, users store.UserStore, cache *UserStatusCache) {
|
||||
tokenHash := auth.HashToken(tokenString)
|
||||
|
||||
apiToken, err := tokenStore.GetByHash(c.Request.Context(), tokenHash)
|
||||
if err != nil || apiToken == nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||
"error": "invalid or expired token",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Verify user is still active
|
||||
if !verifyUserByID(c, apiToken.UserID, users, cache) {
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("user_id", apiToken.UserID)
|
||||
c.Set("auth_method", "pat")
|
||||
c.Set("pat_permissions", apiToken.Permissions)
|
||||
|
||||
// Fire-and-forget: update last_used_at
|
||||
go tokenStore.UpdateLastUsed(context.Background(), apiToken.ID)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
|
||||
// ─── CORS middleware ─────────────────────────────────────────
|
||||
|
||||
// CORS returns a middleware that sets cross-origin headers.
|
||||
@@ -322,7 +364,7 @@ type TicketValidator interface {
|
||||
// When ?token= is used, a deprecation notice is logged. The ticket
|
||||
// path avoids exposing the JWT in server logs, proxy logs, and
|
||||
// browser history.
|
||||
func WsAuth(cfg *config.Config, users store.UserStore, cache *UserStatusCache, tickets TicketValidator) gin.HandlerFunc {
|
||||
func WsAuth(cfg *config.Config, users store.UserStore, cache *UserStatusCache, tickets TicketValidator, tokens ...store.APITokenStore) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if !database.IsConnected() {
|
||||
c.Next()
|
||||
@@ -389,6 +431,12 @@ func WsAuth(cfg *config.Config, users store.UserStore, cache *UserStatusCache, t
|
||||
return
|
||||
}
|
||||
|
||||
// PAT support in WS auth
|
||||
if strings.HasPrefix(tokenString, "arm_pat_") && len(tokens) > 0 && tokens[0] != nil {
|
||||
authenticatePAT(c, tokenString, tokens[0], users, cache)
|
||||
return
|
||||
}
|
||||
|
||||
claims, ok := parseAndValidateJWT(tokenString, cfg.JWTSecret)
|
||||
if !ok {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
|
||||
|
||||
@@ -30,10 +30,26 @@ func RequirePermission(perm string, stores store.Stores) gin.HandlerFunc {
|
||||
}
|
||||
|
||||
// resolveAndCachePerms loads the user's effective permissions once per request.
|
||||
// For PAT-authenticated requests, uses the token's stored permissions directly
|
||||
// (git model: token retains permissions even if user later loses them).
|
||||
func resolveAndCachePerms(c *gin.Context, stores store.Stores, userID string) (map[string]bool, error) {
|
||||
if cached, exists := c.Get(permCacheKey); exists {
|
||||
return cached.(map[string]bool), nil
|
||||
}
|
||||
|
||||
// PAT path: use token's stored permissions directly
|
||||
if c.GetString("auth_method") == "pat" {
|
||||
if patPerms, exists := c.Get("pat_permissions"); exists {
|
||||
perms := make(map[string]bool)
|
||||
for _, p := range patPerms.([]string) {
|
||||
perms[p] = true
|
||||
}
|
||||
c.Set(permCacheKey, perms)
|
||||
return perms, nil
|
||||
}
|
||||
}
|
||||
|
||||
// JWT path: resolve from groups
|
||||
perms, err := auth.ResolvePermissions(c.Request.Context(), stores, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
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)
|
||||
|
||||
// Always available — read-only check against kernel permission data
|
||||
modules["permissions"] = BuildPermissionsModule(ctx, r.stores)
|
||||
|
||||
// Allows any starlark package to load declared library dependencies.
|
||||
if lc != nil {
|
||||
modules["lib"] = BuildLibModule(ctx, r, packageID, rc, lc)
|
||||
|
||||
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
|
||||
ScheduledTasks ScheduledTaskStore
|
||||
Cluster ClusterStore
|
||||
APITokens APITokenStore
|
||||
}
|
||||
|
||||
// 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(),
|
||||
ScheduledTasks: NewScheduledTaskStore(),
|
||||
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(),
|
||||
Triggers: NewTriggerStore(),
|
||||
ScheduledTasks: NewScheduledTaskStore(),
|
||||
APITokens: NewAPITokenStore(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ export default function GroupsSection() {
|
||||
const [detail, setDetail] = useState(null);
|
||||
const [members, setMembers] = useState([]);
|
||||
const [allPerms, setAllPerms] = useState([]);
|
||||
const [groupedPerms, setGroupedPerms] = useState({});
|
||||
const [allUsers, setAllUsers] = useState([]);
|
||||
const [groupData, setGroupData] = useState({});
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
@@ -41,6 +42,7 @@ export default function GroupsSection() {
|
||||
]);
|
||||
setMembers(m || []);
|
||||
setAllPerms(p.permissions || []);
|
||||
setGroupedPerms(p.grouped || {});
|
||||
setAllUsers(u || []);
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
@@ -131,16 +133,37 @@ export default function GroupsSection() {
|
||||
|
||||
<div class="settings-section" style="margin-bottom:16px;">
|
||||
<h5 style="margin:0 0 8px;">Permissions</h5>
|
||||
<div class="admin-permissions-grid">
|
||||
${allPerms.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>
|
||||
${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">
|
||||
${allPerms.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>
|
||||
|
||||
<div style="margin-bottom:16px;">
|
||||
|
||||
@@ -87,6 +87,17 @@ export default function UsersSection() {
|
||||
} catch (e) { sw.toast(e.message, 'error'); }
|
||||
}
|
||||
|
||||
async function createTokenForUser(userId, username) {
|
||||
const name = await sw.prompt(`Token name for "${username}":`, '');
|
||||
if (!name) return;
|
||||
try {
|
||||
const resp = await sw.api.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) {
|
||||
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>`;
|
||||
@@ -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>`
|
||||
}
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,7 @@ const sectionModules = {
|
||||
teams: () => import('./teams.js'),
|
||||
connections: () => import('./connections.js'),
|
||||
notifications: () => import('./notifications.js'),
|
||||
tokens: () => import('./tokens.js'),
|
||||
};
|
||||
|
||||
// ── Nav structure ───────────────────────────
|
||||
@@ -35,6 +36,7 @@ const NAV_ITEMS = [
|
||||
{ key: 'teams', label: 'Teams' },
|
||||
{ key: 'connections', label: 'Connections' },
|
||||
{ key: 'notifications', label: 'Notifications' },
|
||||
{ key: 'tokens', label: 'API Tokens' },
|
||||
];
|
||||
|
||||
// ── Section title map ───────────────────────
|
||||
@@ -42,6 +44,7 @@ const SECTION_TITLES = {
|
||||
general: 'General', appearance: 'Appearance',
|
||||
profile: 'Profile', teams: 'Teams',
|
||||
connections: 'Connections', notifications: 'Notifications',
|
||||
tokens: 'API Tokens',
|
||||
};
|
||||
|
||||
// ── Extension config sections ──────
|
||||
|
||||
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