Feat v0.7.7 API tokens + extension permissions (#61)

Personal access tokens (PATs) for programmatic API access with
SHA-256 hashing, permission scoping (git model), and Settings/Admin UI.
Extension-declared user permissions with dynamic registry, gate_permission
manifest field, permissions Starlark module, and grouped admin UI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-02 18:20:16 +00:00
parent e02b13dc12
commit f32eefab14
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()
}