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:
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()
|
||||
}
|
||||
Reference in New Issue
Block a user