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