feat: personal access tokens with scoped bearer auth

Add cnp_ personal access tokens so the CLI, MCP server, and external
agents authenticate without a browser session — the agent-first spine
from ROADMAP2 phase A. Modeled on agent_auth_tokens: raw token shown
once, only SHA-256 hash stored.

- user_tokens table (goose migration + sqlc), name/prefix/scope/expiry,
  owner-scoped revoke, last_used_at stamping
- middleware: cnp_ bearer resolution, read scope gates mutations with
  403 SCOPE_INSUFFICIENT, non-admin scopes cap is_admin even for admin
  owners — a token never outranks its scope or its owner
- GET/POST/DELETE /api/v1/user/tokens + OpenAPI + generated types
- Settings UI: create with scope/expiry pickers, one-time token display
  with copy, list with last-used/expiry, confirm-revoke
- docs: OPERATIONS.md section, README feature line

Verified end-to-end against live Postgres: create/list/revoke, read
scope blocks PUT (403), write PAT on admin owner blocked from admin
routes (403), admin scope works, revoked token returns 401.

Authored By: TDvorak <[email protected]>
This commit is contained in:
Tomas Dvorak committed 2026-10-08 14:31:29 +02:00
1 parent f9ae5b962c
commit e881747388
17 files changed
+1141 -30

No files matched your search

+5
View File
@@ -260,6 +260,11 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
authed.GET("/user/profile", handleGetProfile)
authed.PUT("/user/profile", handleUpdateProfile)
// Personal access tokens for CLI/MCP/agent auth.
authed.GET("/user/tokens", handleListUserTokens)
authed.POST("/user/tokens", handleCreateUserToken)
authed.DELETE("/user/tokens/:id", handleDeleteUserToken)
authed.GET("/notifications", handleListNotifications)
authed.POST("/notifications/:id/read", handleMarkNotificationRead)
authed.POST("/notifications/read-all", handleMarkAllNotificationsRead)
+194
View File
@@ -0,0 +1,194 @@
package api
import (
"crypto/rand"
"crypto/sha256"
"database/sql"
"encoding/hex"
"net/http"
"strings"
"time"
"containr/internal/database"
sqlcdb "containr/internal/database/sqlcdb"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
// Personal access tokens let the CLI, MCP server, and third-party agents
// authenticate without a browser session. The raw token (cnp_…) is
// returned exactly once at creation; only its sha256 hash persists.
var userTokenScopes = map[string]bool{"read": true, "write": true, "admin": true}
func generateUserToken() (token string, prefix string, hash string, err error) {
buf := make([]byte, 24)
if _, err = rand.Read(buf); err != nil {
return "", "", "", err
}
token = "cnp_" + hex.EncodeToString(buf)
prefix = token[:12] // cnp_ + 8 hex chars, enough to identify in a list
sum := sha256.Sum256([]byte(token))
return token, prefix, hex.EncodeToString(sum[:]), nil
}
func userTokenJSON(t sqlcdb.UserToken) gin.H {
row := gin.H{
"id": t.ID.String(),
"name": t.Name,
"key_prefix": t.KeyPrefix,
"scope": t.Scope,
}
if t.CreatedAt.Valid {
row["created_at"] = t.CreatedAt.Time
}
if t.LastUsedAt.Valid {
row["last_used_at"] = t.LastUsedAt.Time
}
if t.ExpiresAt.Valid {
row["expires_at"] = t.ExpiresAt.Time
}
return row
}
func handleCreateUserToken(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)
var req struct {
Name string `json:"name"`
Scope string `json:"scope"`
ExpiresInDays int `json:"expires_in_days"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
req.Name = strings.TrimSpace(req.Name)
if req.Name == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
return
}
if req.Scope == "" {
req.Scope = "write"
}
if !userTokenScopes[req.Scope] {
c.JSON(http.StatusBadRequest, gin.H{"error": "scope must be one of: read, write, admin"})
return
}
// A token can never outrank its owner: non-admin users cannot mint
// admin-scope tokens even for themselves.
if req.Scope == "admin" && !c.GetBool("is_admin") {
c.JSON(http.StatusForbidden, gin.H{"error": "admin scope requires a platform admin account"})
return
}
if req.ExpiresInDays < 0 || req.ExpiresInDays > 3650 {
c.JSON(http.StatusBadRequest, gin.H{"error": "expires_in_days must be between 0 (never) and 3650"})
return
}
token, prefix, hash, err := generateUserToken()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
return
}
ownerUUID, err := uuid.Parse(userID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"})
return
}
expiresAt := sql.NullTime{}
if req.ExpiresInDays > 0 {
expiresAt = sql.NullTime{Time: time.Now().Add(time.Duration(req.ExpiresInDays) * 24 * time.Hour), Valid: true}
}
row, err := queries.CreateUserToken(c.Request.Context(), sqlcdb.CreateUserTokenParams{
UserID: ownerUUID,
Name: req.Name,
KeyPrefix: prefix,
TokenHash: hash,
Scope: req.Scope,
ExpiresAt: expiresAt,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to store token"})
return
}
LogAudit(userID, "user_token", row.ID.String(), "create", map[string]interface{}{"name": req.Name, "scope": req.Scope})
payload := userTokenJSON(row)
payload["token"] = token
c.JSON(http.StatusCreated, payload)
}
func handleListUserTokens(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)
ownerUUID, err := uuid.Parse(userID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"})
return
}
rows, err := queries.ListUserTokens(c.Request.Context(), ownerUUID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch tokens"})
return
}
out := make([]gin.H, 0, len(rows))
for _, t := range rows {
out = append(out, userTokenJSON(t))
}
c.JSON(http.StatusOK, gin.H{"tokens": out})
}
func handleDeleteUserToken(c *gin.Context) {
userID, ok := requireAuthenticatedUserID(c)
if !ok {
return
}
db := c.MustGet("db").(*database.DB)
queries := sqlcdb.New(db.DB)
ownerUUID, err := uuid.Parse(userID)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid user context"})
return
}
tokenUUID, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid token ID"})
return
}
affected, err := queries.RevokeUserToken(c.Request.Context(), sqlcdb.RevokeUserTokenParams{
ID: tokenUUID,
UserID: ownerUUID,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to revoke token"})
return
}
if affected == 0 {
c.JSON(http.StatusNotFound, gin.H{"error": "Token not found"})
return
}
LogAudit(userID, "user_token", tokenUUID.String(), "revoke", nil)
c.JSON(http.StatusOK, gin.H{"message": "Token revoked"})
}
+46 -27
View File
@@ -168,33 +168,39 @@ type ProjectMember struct {
}
type Service struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Description sql.NullString `json:"description"`
ProjectID uuid.UUID `json:"project_id"`
EnvironmentID uuid.UUID `json:"environment_id"`
ServiceType string `json:"service_type"`
SourceType string `json:"source_type"`
SourceUrl sql.NullString `json:"source_url"`
ImageName sql.NullString `json:"image_name"`
BuildCommand sql.NullString `json:"build_command"`
StartCommand sql.NullString `json:"start_command"`
CpuLimit sql.NullInt32 `json:"cpu_limit"`
MemoryLimit sql.NullInt32 `json:"memory_limit"`
PublicUrl sql.NullString `json:"public_url"`
HealthCheckUrl sql.NullString `json:"health_check_url"`
Status sql.NullString `json:"status"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
Type sql.NullString `json:"type"`
Image sql.NullString `json:"image"`
Command sql.NullString `json:"command"`
Environment sql.NullString `json:"environment"`
GitRepo sql.NullString `json:"git_repo"`
GitBranch sql.NullString `json:"git_branch"`
BuildPath sql.NullString `json:"build_path"`
Cpu sql.NullString `json:"cpu"`
Memory sql.NullString `json:"memory"`
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Description sql.NullString `json:"description"`
ProjectID uuid.UUID `json:"project_id"`
EnvironmentID uuid.UUID `json:"environment_id"`
ServiceType string `json:"service_type"`
SourceType string `json:"source_type"`
SourceUrl sql.NullString `json:"source_url"`
ImageName sql.NullString `json:"image_name"`
BuildCommand sql.NullString `json:"build_command"`
StartCommand sql.NullString `json:"start_command"`
CpuLimit sql.NullInt32 `json:"cpu_limit"`
MemoryLimit sql.NullInt32 `json:"memory_limit"`
PublicUrl sql.NullString `json:"public_url"`
HealthCheckUrl sql.NullString `json:"health_check_url"`
Status sql.NullString `json:"status"`
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
Type sql.NullString `json:"type"`
Image sql.NullString `json:"image"`
Command sql.NullString `json:"command"`
Environment sql.NullString `json:"environment"`
GitRepo sql.NullString `json:"git_repo"`
GitBranch sql.NullString `json:"git_branch"`
BuildPath sql.NullString `json:"build_path"`
Cpu sql.NullString `json:"cpu"`
Memory sql.NullString `json:"memory"`
Replicas int32 `json:"replicas"`
Port int32 `json:"port"`
Domain string `json:"domain"`
HealthcheckPath string `json:"healthcheck_path"`
RestartPolicy string `json:"restart_policy"`
PublishedPort int32 `json:"published_port"`
}
type ServiceTemplate struct {
@@ -211,3 +217,16 @@ type ServiceTemplate struct {
CreatedAt sql.NullTime `json:"created_at"`
UpdatedAt sql.NullTime `json:"updated_at"`
}
type UserToken struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
Name string `json:"name"`
KeyPrefix string `json:"key_prefix"`
TokenHash string `json:"token_hash"`
Scope string `json:"scope"`
ExpiresAt sql.NullTime `json:"expires_at"`
LastUsedAt sql.NullTime `json:"last_used_at"`
RevokedAt sql.NullTime `json:"revoked_at"`
CreatedAt sql.NullTime `json:"created_at"`
}
@@ -26,6 +26,7 @@ type Querier interface {
CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error)
CreateServiceFromTemplate(ctx context.Context, arg CreateServiceFromTemplateParams) error
CreateUserTemplate(ctx context.Context, arg CreateUserTemplateParams) error
CreateUserToken(ctx context.Context, arg CreateUserTokenParams) (UserToken, error)
DatabaseServiceExistsByIDAndUser(ctx context.Context, arg DatabaseServiceExistsByIDAndUserParams) (bool, error)
DeleteAgent(ctx context.Context, id string) error
DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error
@@ -46,6 +47,7 @@ type Querier interface {
GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
GetProjectRoleForUser(ctx context.Context, arg GetProjectRoleForUserParams) (string, error)
GetServiceTemplateByID(ctx context.Context, id string) (ServiceTemplate, error)
GetUserTokenByHash(ctx context.Context, tokenHash string) (UserToken, error)
InsertAgentHeartbeat(ctx context.Context, arg InsertAgentHeartbeatParams) error
InsertProjectEnvironment(ctx context.Context, arg InsertProjectEnvironmentParams) error
ListAgentAuthTokens(ctx context.Context) ([]AgentAuthToken, error)
@@ -66,9 +68,11 @@ type Querier interface {
ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error)
ListServiceTemplatesByCategoryForUser(ctx context.Context, arg ListServiceTemplatesByCategoryForUserParams) ([]ServiceTemplate, error)
ListServiceTemplatesForUser(ctx context.Context, ownerID uuid.NullUUID) ([]ServiceTemplate, error)
ListUserTokens(ctx context.Context, userID uuid.UUID) ([]UserToken, error)
MarkAllNotificationsReadByUser(ctx context.Context, arg MarkAllNotificationsReadByUserParams) error
MarkNotificationReadByIDAndUser(ctx context.Context, arg MarkNotificationReadByIDAndUserParams) error
RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error)
RevokeUserToken(ctx context.Context, arg RevokeUserTokenParams) (int64, error)
SetDatabaseBackupScheduleByIDAndUser(ctx context.Context, arg SetDatabaseBackupScheduleByIDAndUserParams) error
SetDatabaseBackupStatusByID(ctx context.Context, arg SetDatabaseBackupStatusByIDParams) error
SetDatabaseNextBackupAt(ctx context.Context, arg SetDatabaseNextBackupAtParams) error
@@ -77,6 +81,7 @@ type Querier interface {
SetDatabaseServiceStatusByIDAndUser(ctx context.Context, arg SetDatabaseServiceStatusByIDAndUserParams) error
SetProjectApproved(ctx context.Context, arg SetProjectApprovedParams) (int64, error)
TouchAgentAuthToken(ctx context.Context, tokenHash string) error
TouchUserToken(ctx context.Context, id uuid.UUID) error
UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeAgent, error)
UpdateAgentHeartbeat(ctx context.Context, arg UpdateAgentHeartbeatParams) error
UpdateContainerStatus(ctx context.Context, arg UpdateContainerStatusParams) error
@@ -0,0 +1,147 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: user_tokens.sql
package sqlcdb
import (
"context"
"database/sql"
"github.com/google/uuid"
)
const createUserToken = `-- name: CreateUserToken :one
INSERT INTO user_tokens (user_id, name, key_prefix, token_hash, scope, expires_at)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, user_id, name, key_prefix, token_hash, scope, expires_at, last_used_at, revoked_at, created_at
`
type CreateUserTokenParams struct {
UserID uuid.UUID `json:"user_id"`
Name string `json:"name"`
KeyPrefix string `json:"key_prefix"`
TokenHash string `json:"token_hash"`
Scope string `json:"scope"`
ExpiresAt sql.NullTime `json:"expires_at"`
}
func (q *Queries) CreateUserToken(ctx context.Context, arg CreateUserTokenParams) (UserToken, error) {
row := q.db.QueryRowContext(ctx, createUserToken,
arg.UserID,
arg.Name,
arg.KeyPrefix,
arg.TokenHash,
arg.Scope,
arg.ExpiresAt,
)
var i UserToken
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.KeyPrefix,
&i.TokenHash,
&i.Scope,
&i.ExpiresAt,
&i.LastUsedAt,
&i.RevokedAt,
&i.CreatedAt,
)
return i, err
}
const getUserTokenByHash = `-- name: GetUserTokenByHash :one
SELECT id, user_id, name, key_prefix, token_hash, scope, expires_at, last_used_at, revoked_at, created_at FROM user_tokens
WHERE token_hash = $1
AND revoked_at IS NULL
AND (expires_at IS NULL OR expires_at > NOW())
LIMIT 1
`
func (q *Queries) GetUserTokenByHash(ctx context.Context, tokenHash string) (UserToken, error) {
row := q.db.QueryRowContext(ctx, getUserTokenByHash, tokenHash)
var i UserToken
err := row.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.KeyPrefix,
&i.TokenHash,
&i.Scope,
&i.ExpiresAt,
&i.LastUsedAt,
&i.RevokedAt,
&i.CreatedAt,
)
return i, err
}
const listUserTokens = `-- name: ListUserTokens :many
SELECT id, user_id, name, key_prefix, token_hash, scope, expires_at, last_used_at, revoked_at, created_at FROM user_tokens
WHERE user_id = $1 AND revoked_at IS NULL
ORDER BY created_at DESC
`
func (q *Queries) ListUserTokens(ctx context.Context, userID uuid.UUID) ([]UserToken, error) {
rows, err := q.db.QueryContext(ctx, listUserTokens, userID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []UserToken{}
for rows.Next() {
var i UserToken
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.Name,
&i.KeyPrefix,
&i.TokenHash,
&i.Scope,
&i.ExpiresAt,
&i.LastUsedAt,
&i.RevokedAt,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const revokeUserToken = `-- name: RevokeUserToken :execrows
UPDATE user_tokens SET revoked_at = NOW()
WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL
`
type RevokeUserTokenParams struct {
ID uuid.UUID `json:"id"`
UserID uuid.UUID `json:"user_id"`
}
func (q *Queries) RevokeUserToken(ctx context.Context, arg RevokeUserTokenParams) (int64, error) {
result, err := q.db.ExecContext(ctx, revokeUserToken, arg.ID, arg.UserID)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
const touchUserToken = `-- name: TouchUserToken :exec
UPDATE user_tokens SET last_used_at = NOW()
WHERE id = $1 AND revoked_at IS NULL
`
func (q *Queries) TouchUserToken(ctx context.Context, id uuid.UUID) error {
_, err := q.db.ExecContext(ctx, touchUserToken, id)
return err
}
+75 -1
View File
@@ -2,7 +2,10 @@ package middleware
import (
"containr/internal/database"
sqlcdb "containr/internal/database/sqlcdb"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -75,6 +78,14 @@ func RequestID() gin.HandlerFunc {
func resolveRequestUser(c *gin.Context, jwtSecret string, sessionVerifier *betterAuthSessionVerifier) (userID, email, tokenErr string, hasToken, ok bool) {
tokenString, tokenErr, hasToken := extractJWTToken(c)
if tokenString != "" {
// Personal access tokens (cnp_…) are not JWTs — they resolve
// against the user_tokens table by sha256 hash.
if strings.HasPrefix(tokenString, patPrefix) {
if id, mail, verified := resolvePersonalAccessToken(c, tokenString); verified {
return id, mail, "", hasToken, true
}
return "", "", "Invalid token", hasToken, false
}
if claims, valid := validateJWTClaims(tokenString, jwtSecret); valid {
userIDClaim, exists := claims["user_id"]
if exists {
@@ -101,12 +112,67 @@ func resolveRequestUser(c *gin.Context, jwtSecret string, sessionVerifier *bette
return "", "", tokenErr, hasToken, false
}
const patPrefix = "cnp_"
// resolvePersonalAccessToken authenticates a `cnp_` bearer token against the
// user_tokens table (sha256 hash match, not revoked, not expired). On success
// it stamps last_used_at, records the token scope on the context, and
// returns the owner's id and email.
func resolvePersonalAccessToken(c *gin.Context, token string) (string, string, bool) {
dbValue, exists := c.Get("db")
if !exists {
return "", "", false
}
db, ok := dbValue.(*database.DB)
if !ok || db == nil || db.DB == nil {
return "", "", false
}
sum := sha256.Sum256([]byte(token))
queries := sqlcdb.New(db.DB)
row, err := queries.GetUserTokenByHash(c.Request.Context(), hex.EncodeToString(sum[:]))
if err != nil {
return "", "", false
}
var email string
if err := db.QueryRowContext(c.Request.Context(),
`SELECT email FROM users WHERE id = $1`, row.UserID).Scan(&email); err != nil {
return "", "", false
}
_ = queries.TouchUserToken(c.Request.Context(), row.ID)
c.Set("pat_scope", row.Scope)
return row.UserID.String(), email, true
}
// patScopeAllows reports whether a PAT-authenticated request may proceed.
// read scope permits only safe methods; write and admin are unrestricted at
// this layer (admin routes still require the admin scope AND a platform
// admin account — enforced via is_admin in setAuthenticatedContext).
func patScopeAllows(c *gin.Context) bool {
if c.GetString("pat_scope") != "read" {
return true
}
switch c.Request.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
return true
}
return false
}
// setAuthenticatedContext stores the resolved identity plus the admin flag on
// the gin context so handlers and RequireAdmin can rely on it.
func setAuthenticatedContext(c *gin.Context, userID, email string) {
c.Set("user_id", userID)
c.Set("email", email)
c.Set("is_admin", loadIsAdmin(c, userID))
isAdmin := loadIsAdmin(c, userID)
// A PAT can never outrank its scope: non-admin scopes cap the token at
// non-admin even when the owning user is a platform admin.
if scope := c.GetString("pat_scope"); scope != "" && scope != "admin" {
isAdmin = false
}
c.Set("is_admin", isAdmin)
}
func loadIsAdmin(c *gin.Context, userID string) bool {
@@ -133,6 +199,14 @@ func Auth(jwtSecret string) gin.HandlerFunc {
userID, email, tokenErr, hasToken, ok := resolveRequestUser(c, jwtSecret, sessionVerifier)
if ok {
setAuthenticatedContext(c, userID, email)
if !patScopeAllows(c) {
c.JSON(http.StatusForbidden, gin.H{
"error": "Token scope is read-only",
"code": "SCOPE_INSUFFICIENT",
})
c.Abort()
return
}
c.Next()
return
}
@@ -131,6 +131,61 @@ func TestAuthStoresStringUserIDForValidClaims(t *testing.T) {
}
}
func TestAuthRejectsPATWithoutDatabase(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(Auth("secret"))
router.GET("/test", func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})
// A cnp_-prefixed token takes the PAT path, not JWT parsing — without a
// db handle it must fail closed with 401.
req := httptest.NewRequest(http.MethodGet, "/test", nil)
req.Header.Set("Authorization", "Bearer cnp_"+strings.Repeat("ab", 24))
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("expected status %d, got %d", http.StatusUnauthorized, rec.Code)
}
}
func TestPATScopeAllows(t *testing.T) {
gin.SetMode(gin.TestMode)
cases := []struct {
name string
scope string
method string
want bool
}{
{"read allows GET", "read", http.MethodGet, true},
{"read allows HEAD", "read", http.MethodHead, true},
{"read allows OPTIONS", "read", http.MethodOptions, true},
{"read blocks POST", "read", http.MethodPost, false},
{"read blocks DELETE", "read", http.MethodDelete, false},
{"write allows POST", "write", http.MethodPost, true},
{"write allows DELETE", "write", http.MethodDelete, true},
{"admin allows PATCH", "admin", http.MethodPatch, true},
{"no scope (session/JWT) unrestricted", "", http.MethodPost, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(tc.method, "/x", nil)
if tc.scope != "" {
c.Set("pat_scope", tc.scope)
}
if got := patScopeAllows(c); got != tc.want {
t.Fatalf("patScopeAllows(scope=%q, method=%s) = %v, want %v", tc.scope, tc.method, got, tc.want)
}
})
}
}
func issueJWT(t *testing.T, secret string, claims jwt.MapClaims) string {
t.Helper()
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
@@ -0,0 +1,25 @@
-- +goose Up
-- Personal access tokens for CLI/MCP/agent authentication.
-- Only sha256 hashes are stored — raw tokens (cnp_…) are shown once at
-- creation and never persisted. Scope gates what the token may do:
-- read = GET/HEAD only, write = all non-admin routes, admin = everything
-- (admin still requires the owning user to be a platform admin).
CREATE TABLE IF NOT EXISTS public.user_tokens (
id uuid DEFAULT gen_random_uuid() NOT NULL PRIMARY KEY,
user_id uuid NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
name character varying(255) NOT NULL,
key_prefix character varying(16) NOT NULL,
token_hash text NOT NULL UNIQUE,
scope character varying(20) NOT NULL DEFAULT 'write'
CHECK (scope IN ('read', 'write', 'admin')),
expires_at timestamp with time zone,
last_used_at timestamp with time zone,
revoked_at timestamp with time zone,
created_at timestamp with time zone DEFAULT now() NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_user_tokens_user_id ON public.user_tokens(user_id);
CREATE INDEX IF NOT EXISTS idx_user_tokens_token_hash ON public.user_tokens(token_hash);
-- +goose Down
DROP TABLE IF EXISTS public.user_tokens;
+24
View File
@@ -0,0 +1,24 @@
-- name: CreateUserToken :one
INSERT INTO user_tokens (user_id, name, key_prefix, token_hash, scope, expires_at)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING *;
-- name: ListUserTokens :many
SELECT * FROM user_tokens
WHERE user_id = $1 AND revoked_at IS NULL
ORDER BY created_at DESC;
-- name: GetUserTokenByHash :one
SELECT * FROM user_tokens
WHERE token_hash = $1
AND revoked_at IS NULL
AND (expires_at IS NULL OR expires_at > NOW())
LIMIT 1;
-- name: TouchUserToken :exec
UPDATE user_tokens SET last_used_at = NOW()
WHERE id = $1 AND revoked_at IS NULL;
-- name: RevokeUserToken :execrows
UPDATE user_tokens SET revoked_at = NOW()
WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL;
+13
View File
@@ -204,3 +204,16 @@ CREATE TABLE notifications (
read_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE TABLE user_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
key_prefix VARCHAR(16) NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
scope VARCHAR(20) NOT NULL DEFAULT 'write',
expires_at TIMESTAMP WITH TIME ZONE,
last_used_at TIMESTAMP WITH TIME ZONE,
revoked_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);