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

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 {