mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 15:51:26 +00:00
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]>
195 lines
5.1 KiB
Go
195 lines
5.1 KiB
Go
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"})
|
|
}
|