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

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:
2026-04-02 19:11:47 +00:00
committed by xcaliber
parent e02b13dc12
commit e4f0bdbd36
34 changed files with 1769 additions and 58 deletions

13
server/auth/hash.go Normal file
View 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[:])
}

View File

@@ -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.

View 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()
}

View 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;

View 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);

View 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 }

View 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
}

View File

@@ -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.

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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.

View File

@@ -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 ────────────────────────────────

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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{

View File

@@ -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

View 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"`
}

View 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
}),
})
}

View File

@@ -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)

View 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
}

View File

@@ -62,6 +62,7 @@ type Stores struct {
Triggers TriggerStore
ScheduledTasks ScheduledTaskStore
Cluster ClusterStore
APITokens APITokenStore
}
// TeamAvailableModel is returned by CatalogStore.ListTeamAvailable.

View 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
}

View File

@@ -33,5 +33,6 @@ func NewStores(db *sql.DB) store.Stores {
Triggers: NewTriggerStore(),
ScheduledTasks: NewScheduledTaskStore(),
Cluster: NewClusterStore(),
APITokens: NewAPITokenStore(db),
}
}

View 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
}

View File

@@ -32,5 +32,6 @@ func NewStores(db *sql.DB) store.Stores {
RateLimits: NewRateLimitStore(),
Triggers: NewTriggerStore(),
ScheduledTasks: NewScheduledTaskStore(),
APITokens: NewAPITokenStore(),
}
}