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:
34
server/store/api_token_iface.go
Normal file
34
server/store/api_token_iface.go
Normal 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
|
||||
}
|
||||
@@ -62,6 +62,7 @@ type Stores struct {
|
||||
Triggers TriggerStore
|
||||
ScheduledTasks ScheduledTaskStore
|
||||
Cluster ClusterStore
|
||||
APITokens APITokenStore
|
||||
}
|
||||
|
||||
// TeamAvailableModel is returned by CatalogStore.ListTeamAvailable.
|
||||
|
||||
125
server/store/postgres/api_tokens.go
Normal file
125
server/store/postgres/api_tokens.go
Normal 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
|
||||
}
|
||||
@@ -33,5 +33,6 @@ func NewStores(db *sql.DB) store.Stores {
|
||||
Triggers: NewTriggerStore(),
|
||||
ScheduledTasks: NewScheduledTaskStore(),
|
||||
Cluster: NewClusterStore(),
|
||||
APITokens: NewAPITokenStore(db),
|
||||
}
|
||||
}
|
||||
|
||||
172
server/store/sqlite/api_tokens.go
Normal file
172
server/store/sqlite/api_tokens.go
Normal 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
|
||||
}
|
||||
@@ -32,5 +32,6 @@ func NewStores(db *sql.DB) store.Stores {
|
||||
RateLimits: NewRateLimitStore(),
|
||||
Triggers: NewTriggerStore(),
|
||||
ScheduledTasks: NewScheduledTaskStore(),
|
||||
APITokens: NewAPITokenStore(),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user