diff --git a/README.md b/README.md index 0498a87..d0b9fff 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ dedicated console. - **Template catalog** — 30 official templates (NocoDB, Plausible, MinIO, n8n, …) plus user-defined templates with JSON upload and owner-scoped edit/delete - **Metrics** — per-service Docker stats plus host CPU/memory/disk telemetry - **HA & scaling** — failover policies, node agents, autoscaling rules -- **Auth & security** — Better Auth sessions (email/password self-hosted), audit logs, vulnerability scans +- **Auth & security** — Better Auth sessions (email/password self-hosted), personal access tokens with `read`/`write`/`admin` scopes for CLI and agents, audit logs, vulnerability scans - **Integrated docs** — searchable documentation rendered in-app on the landing page and `/docs`; syncs from GitHub, falls back to a bundled snapshot offline - **Networking** — Traefik reverse proxy, optional Cloudflare Tunnel diff --git a/app/backend/internal/api/routes.go b/app/backend/internal/api/routes.go index 2114bd0..9bb7d58 100644 --- a/app/backend/internal/api/routes.go +++ b/app/backend/internal/api/routes.go @@ -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) diff --git a/app/backend/internal/api/user_tokens.go b/app/backend/internal/api/user_tokens.go new file mode 100644 index 0000000..86e819b --- /dev/null +++ b/app/backend/internal/api/user_tokens.go @@ -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"}) +} diff --git a/app/backend/internal/database/sqlcdb/models.go b/app/backend/internal/database/sqlcdb/models.go index b424a62..d1521eb 100644 --- a/app/backend/internal/database/sqlcdb/models.go +++ b/app/backend/internal/database/sqlcdb/models.go @@ -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"` +} diff --git a/app/backend/internal/database/sqlcdb/querier.go b/app/backend/internal/database/sqlcdb/querier.go index 1f1ef83..343dea9 100644 --- a/app/backend/internal/database/sqlcdb/querier.go +++ b/app/backend/internal/database/sqlcdb/querier.go @@ -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 diff --git a/app/backend/internal/database/sqlcdb/user_tokens.sql.go b/app/backend/internal/database/sqlcdb/user_tokens.sql.go new file mode 100644 index 0000000..c2a8d71 --- /dev/null +++ b/app/backend/internal/database/sqlcdb/user_tokens.sql.go @@ -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 +} diff --git a/app/backend/internal/middleware/middleware.go b/app/backend/internal/middleware/middleware.go index dafbd8c..9549959 100644 --- a/app/backend/internal/middleware/middleware.go +++ b/app/backend/internal/middleware/middleware.go @@ -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 } diff --git a/app/backend/internal/middleware/middleware_test.go b/app/backend/internal/middleware/middleware_test.go index 103d9e1..bda5f47 100644 --- a/app/backend/internal/middleware/middleware_test.go +++ b/app/backend/internal/middleware/middleware_test.go @@ -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) diff --git a/app/backend/migrations_goose/20261008000000_user_tokens.sql b/app/backend/migrations_goose/20261008000000_user_tokens.sql new file mode 100644 index 0000000..7f15a92 --- /dev/null +++ b/app/backend/migrations_goose/20261008000000_user_tokens.sql @@ -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; diff --git a/app/backend/sqlc/queries/user_tokens.sql b/app/backend/sqlc/queries/user_tokens.sql new file mode 100644 index 0000000..c7a5b69 --- /dev/null +++ b/app/backend/sqlc/queries/user_tokens.sql @@ -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; diff --git a/app/backend/sqlc/schema.sql b/app/backend/sqlc/schema.sql index b85fe2a..564a2a4 100644 --- a/app/backend/sqlc/schema.sql +++ b/app/backend/sqlc/schema.sql @@ -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() +); diff --git a/app/frontend/src/features/secondary/pages.tsx b/app/frontend/src/features/secondary/pages.tsx index af48e90..30f2488 100644 --- a/app/frontend/src/features/secondary/pages.tsx +++ b/app/frontend/src/features/secondary/pages.tsx @@ -17,6 +17,9 @@ import { createAgentToken, listAgentTokens, revokeAgentToken, + createUserToken, + listUserTokens, + revokeUserToken, listDatabases, databaseAction, updateDatabaseBackupSchedule, @@ -46,6 +49,8 @@ import { updateVulnerability, getSecurityMetrics, type AgentAuthTokenCreated, + type UserTokenCreated, + type UserTokenScope, type CreateDatabaseInput, type DatabaseEntity, type FailoverPolicy, @@ -1267,6 +1272,164 @@ function PlatformSettingsSection() { ); } +const PAT_EXPIRY_OPTIONS: { label: string; days: number }[] = [ + { label: 'Never', days: 0 }, + { label: '30 days', days: 30 }, + { label: '90 days', days: 90 }, + { label: '1 year', days: 365 }, +]; + +function UserTokensSection({ isAdmin }: { isAdmin: boolean }) { + const queryClient = useQueryClient(); + const tokensQuery = useQuery({ + queryKey: ['user-tokens'], + queryFn: listUserTokens, + }); + const [tokenName, setTokenName] = useState(''); + const [tokenScope, setTokenScope] = useState('write'); + const [expiryDays, setExpiryDays] = useState(0); + const [issuedToken, setIssuedToken] = useState(null); + + const createMutation = useMutation({ + mutationFn: () => + createUserToken({ + name: tokenName.trim(), + scope: tokenScope, + expiresInDays: expiryDays, + }), + onSuccess: (data) => { + setIssuedToken(data); + setTokenName(''); + queryClient.invalidateQueries({ queryKey: ['user-tokens'] }); + }, + }); + const revokeMutation = useMutation({ + mutationFn: (id: string) => revokeUserToken(id), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['user-tokens'] }), + }); + + const tokens = tokensQuery.data ?? []; + const scopeOptions: { value: UserTokenScope; label: string }[] = isAdmin + ? [ + { value: 'read', label: 'Read - GET requests only' }, + { value: 'write', label: 'Write - read and manage resources' }, + { value: 'admin', label: 'Admin - everything, incl. platform settings' }, + ] + : [ + { value: 'read', label: 'Read - GET requests only' }, + { value: 'write', label: 'Write - read and manage resources' }, + ]; + + return ( +
+
+
+ +
+
+

Personal Access Tokens

+

Authenticate the CLI, MCP server, and scripts - same rights as you, bounded by scope

+
+
+ +
+
+ setTokenName(e.target.value)} + placeholder="Token name (e.g. laptop cli)" + className="h-9 flex-1 min-w-[180px] rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--accent-primary)] transition-all" + /> + + + +
+ + {issuedToken ? ( +
+
+ {issuedToken.token} + +
+

+ Shown once - store it now. Use with containr auth login --token or as a Bearer token. +

+
+ ) : null} + {createMutation.isError ? ( +

+ {createMutation.error instanceof Error ? createMutation.error.message : 'Failed to create token'} +

+ ) : null} + + {tokensQuery.isLoading ? ( +
+ +
+ ) : tokens.length === 0 ? ( +

No tokens yet. Create one to connect the CLI or an agent.

+ ) : ( +
+ {tokens.map((token) => ( +
+
+ {token.name} + {token.key_prefix}… + {token.scope} + + {token.last_used_at ? `used ${formatRelative(token.last_used_at)}` : 'unused'} + {token.expires_at ? ` · expires ${formatRelative(token.expires_at)}` : ''} + +
+ +
+ ))} +
+ )} +
+
+ ); +} + export function SettingsPage() { const navigate = useNavigate(); const queryClient = useQueryClient(); @@ -1438,6 +1601,9 @@ export function SettingsPage() { )} + {/* Personal Access Tokens */} + + {/* Platform Settings - owner only */} {profileQuery.data?.isAdmin ? : null} diff --git a/app/frontend/src/generated/api-types.ts b/app/frontend/src/generated/api-types.ts index ec8e49b..04d56fa 100644 --- a/app/frontend/src/generated/api-types.ts +++ b/app/frontend/src/generated/api-types.ts @@ -231,6 +231,153 @@ export interface paths { patch?: never; trace?: never; }; + "/user/tokens": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List personal access tokens + * @description Returns the caller's non-revoked personal access token metadata. Token values and hashes are never returned. + */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Token list */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + tokens?: components["schemas"]["UserToken"][]; + }; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + put?: never; + /** + * Create personal access token + * @description Issues a `cnp_` bearer token for CLI/MCP/agent authentication. The raw token is returned exactly once and never stored — only its SHA-256 hash persists. Admin-scope tokens require a platform admin account. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateUserTokenRequest"]; + }; + }; + responses: { + /** @description Token created; raw value returned once */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UserTokenCreated"]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Admin scope requires platform admin */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/user/tokens/{id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Revoke personal access token + * @description Marks one of the caller's tokens as revoked. Clients presenting it are rejected from then on. + */ + delete: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Token revoked */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + message?: string; + }; + }; + }; + /** @description Token not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/projects": { parameters: { query?: never; @@ -8476,6 +8623,37 @@ export interface components { /** @description Human-readable label for the token (e.g. node name) */ label?: string; }; + UserToken: { + /** Format: uuid */ + id: string; + name: string; + /** @description First 12 chars of the token, for identification in lists */ + key_prefix: string; + /** @enum {string} */ + scope: "read" | "write" | "admin"; + /** Format: date-time */ + expires_at?: string; + /** Format: date-time */ + last_used_at?: string; + /** Format: date-time */ + created_at: string; + }; + UserTokenCreated: components["schemas"]["UserToken"] & { + /** @description Raw cnp_ token value, returned exactly once at creation */ + token: string; + }; + CreateUserTokenRequest: { + /** @description Human-readable label (e.g. "laptop cli", "ci deploy") */ + name: string; + /** + * @description read = GET/HEAD only; write = all non-admin routes; admin = everything (requires admin account) + * @default write + * @enum {string} + */ + scope: "read" | "write" | "admin"; + /** @description 0 or omitted = never expires */ + expires_in_days?: number; + }; AgentCapabilities: { container_runtimes?: string[]; supported_architectures?: string[]; diff --git a/app/frontend/src/generated/docs-snapshot.ts b/app/frontend/src/generated/docs-snapshot.ts index 00ca67a..16968f3 100644 --- a/app/frontend/src/generated/docs-snapshot.ts +++ b/app/frontend/src/generated/docs-snapshot.ts @@ -52,7 +52,7 @@ export const bundledDocs: BundledDoc[] = [ "path": "guides/OPERATIONS.md", "section": "guides", "title": "Operations", - "markdown": "# Operations\n\nDay-2 features for running services: scheduled jobs, ad-hoc exec, managed\ndatabases, preview environments, security scans, and high availability.\n\n## Cron jobs\n\nService detail → **Cron**. Standard 5-field cron expressions (`robfig/cron`\nsyntax), per-job timezone, enable/disable, manual trigger, and execution\nhistory with captured stdout/stderr.\n\n- The scheduler ticks every minute and runs due jobs via `docker exec`\n (`sh -c `) inside the service's container.\n- Executions are retained per-job (`retention` count) and visible in the UI.\n- Requires a running container — a stopped service's due job records a\n failure, it does not queue.\n\n```\nGET/POST /api/v1/cron-jobs PUT/DELETE /api/v1/cron-jobs/:id\nPOST /api/v1/cron-jobs/:id/trigger\nGET /api/v1/cron-jobs/:id/executions\n```\n\n## Exec console\n\nService detail → **Console**. One-off `sh -c` commands against the running\ncontainer: 30s ceiling, 64KB output cap, exit code + captured output shown\nper run. Debugging aid — not an interactive shell.\n\n```\nPOST /api/v1/services/:id/exec {command}\n```\n\n## Managed databases\n\n**/databases** page — provision PostgreSQL/Redis/MySQL/MariaDB/MongoDB/\nClickHouse/Dragonfly as managed containers on the `containr` network.\n\n- Start/stop/restart actions; connection URL with copy.\n- **Backups**: manual snapshot now, or set a cron expression\n (`backup_schedule`) — due backups run inside the same scheduler tick as\n cron jobs. Archives live in the `containr-db-backups` volume and are\n downloadable (`GET /databases/:id/backups/:bid/download`) and restorable.\n- **Bind to service**: injects `connection_url` into a service's variables\n as a secret (`DATABASE_URL` by default). Redeploy the service afterward —\n variables are read at deploy time.\n\n## Preview environments\n\nService detail → **Previews**. Create a preview per branch/PR with a TTL,\npromote it to production, or delete it.\n\nCurrent limitation: previews are bookkeeping records — they do not build,\ndeploy, or route a real container. The generated `*.preview.containr.local`\nURL is a placeholder. Real preview runtime is tracked on the roadmap.\n\n## Security scans\n\n**/security** page — pick a project, run a scan (dependency / configuration\n/ comprehensive, optionally scoped to one service).\n\nThe scanner is heuristic config analysis, not Trivy/Grype: it flags\nunpinned image tags, `http://` source/public URLs, `npm install` without\n`npm ci`, `pip install` without hashes, `curl|sh` build steps, missing\nhealth checks, and similar smells. Findings get severity badges and can be\nresolved or ignored; the page also shows a security score, open/resolved\ncounts, scan history, and compliance status.\n\n```\nPOST /api/v1/security/scans {project_id, service_id?, scan_type}\nGET /api/v1/projects/:id/vulnerabilities\nPUT /api/v1/vulnerabilities/:id {status: open|resolved|ignored}\nGET /api/v1/projects/:id/security/metrics\nGET /api/v1/projects/:id/security/history\n```\n\n## High availability\n\n**/ha** page — manager enable/disable, node/health-check/alert status\ncards, manual failover (with confirmation), per-service failover policies\n(project → service picker, strategy, min healthy nodes, max failures),\nactive alerts with resolve, and health check results.\n\nHA state is in-memory like autoscaling — policies and alerts reset on\nbackend restart.\n\n## Node agents\n\n**/usage** page → *Connect node*: issue a per-node token (`cagt_…`, shown\nonce), copy the prefilled install command, revoke tokens when done. Agents\nregister over `POST /api/agents/register` and heartbeat via\n`POST /api/agents/heartbeat`.\n" + "markdown": "# Operations\n\nDay-2 features for running services: scheduled jobs, ad-hoc exec, managed\ndatabases, preview environments, security scans, and high availability.\n\n## Cron jobs\n\nService detail → **Cron**. Standard 5-field cron expressions (`robfig/cron`\nsyntax), per-job timezone, enable/disable, manual trigger, and execution\nhistory with captured stdout/stderr.\n\n- The scheduler ticks every minute and runs due jobs via `docker exec`\n (`sh -c `) inside the service's container.\n- Executions are retained per-job (`retention` count) and visible in the UI.\n- Requires a running container — a stopped service's due job records a\n failure, it does not queue.\n\n```\nGET/POST /api/v1/cron-jobs PUT/DELETE /api/v1/cron-jobs/:id\nPOST /api/v1/cron-jobs/:id/trigger\nGET /api/v1/cron-jobs/:id/executions\n```\n\n## Exec console\n\nService detail → **Console**. One-off `sh -c` commands against the running\ncontainer: 30s ceiling, 64KB output cap, exit code + captured output shown\nper run. Debugging aid — not an interactive shell.\n\n```\nPOST /api/v1/services/:id/exec {command}\n```\n\n## Managed databases\n\n**/databases** page — provision PostgreSQL/Redis/MySQL/MariaDB/MongoDB/\nClickHouse/Dragonfly as managed containers on the `containr` network.\n\n- Start/stop/restart actions; connection URL with copy.\n- **Backups**: manual snapshot now, or set a cron expression\n (`backup_schedule`) — due backups run inside the same scheduler tick as\n cron jobs. Archives live in the `containr-db-backups` volume and are\n downloadable (`GET /databases/:id/backups/:bid/download`) and restorable.\n- **Bind to service**: injects `connection_url` into a service's variables\n as a secret (`DATABASE_URL` by default). Redeploy the service afterward —\n variables are read at deploy time.\n\n## Preview environments\n\nService detail → **Previews**. Create a preview per branch/PR with a TTL,\npromote it to production, or delete it.\n\nCurrent limitation: previews are bookkeeping records — they do not build,\ndeploy, or route a real container. The generated `*.preview.containr.local`\nURL is a placeholder. Real preview runtime is tracked on the roadmap.\n\n## Security scans\n\n**/security** page — pick a project, run a scan (dependency / configuration\n/ comprehensive, optionally scoped to one service).\n\nThe scanner is heuristic config analysis, not Trivy/Grype: it flags\nunpinned image tags, `http://` source/public URLs, `npm install` without\n`npm ci`, `pip install` without hashes, `curl|sh` build steps, missing\nhealth checks, and similar smells. Findings get severity badges and can be\nresolved or ignored; the page also shows a security score, open/resolved\ncounts, scan history, and compliance status.\n\n```\nPOST /api/v1/security/scans {project_id, service_id?, scan_type}\nGET /api/v1/projects/:id/vulnerabilities\nPUT /api/v1/vulnerabilities/:id {status: open|resolved|ignored}\nGET /api/v1/projects/:id/security/metrics\nGET /api/v1/projects/:id/security/history\n```\n\n## High availability\n\n**/ha** page — manager enable/disable, node/health-check/alert status\ncards, manual failover (with confirmation), per-service failover policies\n(project → service picker, strategy, min healthy nodes, max failures),\nactive alerts with resolve, and health check results.\n\nHA state is in-memory like autoscaling — policies and alerts reset on\nbackend restart.\n\n## Node agents\n\n**/usage** page → *Connect node*: issue a per-node token (`cagt_…`, shown\nonce), copy the prefilled install command, revoke tokens when done. Agents\nregister over `POST /api/agents/register` and heartbeat via\n`POST /api/agents/heartbeat`.\n\n## Personal access tokens\n\n**Settings → Personal Access Tokens**: issue `cnp_…` bearer tokens for the\nCLI, MCP server, and scripts — the same privileges as your account, bounded\nby scope. The raw token is shown once at creation; only its SHA-256 hash is\nstored.\n\nScopes:\n\n- `read` — `GET`/`HEAD`/`OPTIONS` only; mutations return `403 SCOPE_INSUFFICIENT`.\n- `write` — everything except admin routes.\n- `admin` — full access; requires a platform admin account, and the token is\n still capped at the owner's privileges.\n\nA token can never outrank its owner: non-admin scopes report\n`is_admin = false` even on admin accounts.\n\n```\nGET /api/v1/user/tokens list (metadata only, never hashes)\nPOST /api/v1/user/tokens {name, scope?, expires_in_days?} → raw token once\nDELETE /api/v1/user/tokens/:id revoke (owner-scoped)\n```\n\nUse as `Authorization: Bearer cnp_…`, or `containr auth login cnp_…`.\nRevocation takes effect on the next request; `last_used_at` updates on each\nauthenticated call.\n" }, { "path": "guides/README_BACKEND.md", diff --git a/app/frontend/src/lib/api-client.ts b/app/frontend/src/lib/api-client.ts index f98757e..8dc72b8 100644 --- a/app/frontend/src/lib/api-client.ts +++ b/app/frontend/src/lib/api-client.ts @@ -993,6 +993,34 @@ export async function revokeAgentToken(id: string): Promise { await requestJson(`/agent-tokens/${id}`, { method: 'DELETE' }); } +export type UserToken = components['schemas']['UserToken']; +export type UserTokenCreated = components['schemas']['UserTokenCreated']; +export type UserTokenScope = 'read' | 'write' | 'admin'; + +export async function createUserToken(input: { + name: string; + scope?: UserTokenScope; + expiresInDays?: number; +}): Promise { + return requestJson(`/user/tokens`, { + method: 'POST', + body: JSON.stringify({ + name: input.name, + scope: input.scope ?? 'write', + expires_in_days: input.expiresInDays ?? 0, + }), + }); +} + +export async function listUserTokens(): Promise { + const payload = await requestJson<{ tokens?: UserToken[] }>(`/user/tokens`); + return payload.tokens ?? []; +} + +export async function revokeUserToken(id: string): Promise { + await requestJson(`/user/tokens/${id}`, { method: 'DELETE' }); +} + export async function createProject(input: CreateProjectInput): Promise { const payload = await requestJson(`/projects`, { method: 'POST', diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 6a23059..8869033 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -152,6 +152,97 @@ paths: schema: $ref: '#/components/schemas/Error' + /user/tokens: + get: + tags: + - User + summary: List personal access tokens + description: Returns the caller's non-revoked personal access token metadata. Token values and hashes are never returned. + security: + - bearerAuth: [] + responses: + '200': + description: Token list + content: + application/json: + schema: + type: object + properties: + tokens: + type: array + items: + $ref: '#/components/schemas/UserToken' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + post: + tags: + - User + summary: Create personal access token + description: Issues a `cnp_` bearer token for CLI/MCP/agent authentication. The raw token is returned exactly once and never stored — only its SHA-256 hash persists. Admin-scope tokens require a platform admin account. + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateUserTokenRequest' + responses: + '201': + description: Token created; raw value returned once + content: + application/json: + schema: + $ref: '#/components/schemas/UserTokenCreated' + '400': + description: Invalid input + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '403': + description: Admin scope requires platform admin + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /user/tokens/{id}: + delete: + tags: + - User + summary: Revoke personal access token + description: Marks one of the caller's tokens as revoked. Clients presenting it are rejected from then on. + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Token revoked + content: + application/json: + schema: + type: object + properties: + message: + type: string + '404': + description: Token not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + # Projects /projects: get: @@ -6395,6 +6486,66 @@ components: type: string description: Human-readable label for the token (e.g. node name) + UserToken: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + key_prefix: + type: string + description: First 12 chars of the token, for identification in lists + scope: + type: string + enum: [read, write, admin] + expires_at: + type: string + format: date-time + last_used_at: + type: string + format: date-time + created_at: + type: string + format: date-time + required: + - id + - name + - key_prefix + - scope + - created_at + + UserTokenCreated: + allOf: + - $ref: '#/components/schemas/UserToken' + - type: object + properties: + token: + type: string + description: Raw cnp_ token value, returned exactly once at creation + required: + - token + + CreateUserTokenRequest: + type: object + properties: + name: + type: string + description: Human-readable label (e.g. "laptop cli", "ci deploy") + scope: + type: string + enum: [read, write, admin] + default: write + description: read = GET/HEAD only; write = all non-admin routes; admin = everything (requires admin account) + expires_in_days: + type: integer + minimum: 0 + maximum: 3650 + description: 0 or omitted = never expires + required: + - name + AgentCapabilities: type: object properties: diff --git a/docs/guides/OPERATIONS.md b/docs/guides/OPERATIONS.md index 4a9be55..011a913 100644 --- a/docs/guides/OPERATIONS.md +++ b/docs/guides/OPERATIONS.md @@ -90,3 +90,30 @@ backend restart. once), copy the prefilled install command, revoke tokens when done. Agents register over `POST /api/agents/register` and heartbeat via `POST /api/agents/heartbeat`. + +## Personal access tokens + +**Settings → Personal Access Tokens**: issue `cnp_…` bearer tokens for the +CLI, MCP server, and scripts — the same privileges as your account, bounded +by scope. The raw token is shown once at creation; only its SHA-256 hash is +stored. + +Scopes: + +- `read` — `GET`/`HEAD`/`OPTIONS` only; mutations return `403 SCOPE_INSUFFICIENT`. +- `write` — everything except admin routes. +- `admin` — full access; requires a platform admin account, and the token is + still capped at the owner's privileges. + +A token can never outrank its owner: non-admin scopes report +`is_admin = false` even on admin accounts. + +``` +GET /api/v1/user/tokens list (metadata only, never hashes) +POST /api/v1/user/tokens {name, scope?, expires_in_days?} → raw token once +DELETE /api/v1/user/tokens/:id revoke (owner-scoped) +``` + +Use as `Authorization: Bearer cnp_…`, or `containr auth login cnp_…`. +Revocation takes effect on the next request; `last_used_at` updates on each +authenticated call.