mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
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:
1 parent
f9ae5b962c
commit
e881747388
17 files changed
+1141
-30
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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()
|
||||
);
|
||||
Reference in new issue
Block a user