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
@@ -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
|
- **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
|
- **Metrics** — per-service Docker stats plus host CPU/memory/disk telemetry
|
||||||
- **HA & scaling** — failover policies, node agents, autoscaling rules
|
- **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
|
- **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
|
- **Networking** — Traefik reverse proxy, optional Cloudflare Tunnel
|
||||||
|
|
||||||
|
|||||||
@@ -260,6 +260,11 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
|
|||||||
authed.GET("/user/profile", handleGetProfile)
|
authed.GET("/user/profile", handleGetProfile)
|
||||||
authed.PUT("/user/profile", handleUpdateProfile)
|
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.GET("/notifications", handleListNotifications)
|
||||||
authed.POST("/notifications/:id/read", handleMarkNotificationRead)
|
authed.POST("/notifications/:id/read", handleMarkNotificationRead)
|
||||||
authed.POST("/notifications/read-all", handleMarkAllNotificationsRead)
|
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 {
|
type Service struct {
|
||||||
ID uuid.UUID `json:"id"`
|
ID uuid.UUID `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description sql.NullString `json:"description"`
|
Description sql.NullString `json:"description"`
|
||||||
ProjectID uuid.UUID `json:"project_id"`
|
ProjectID uuid.UUID `json:"project_id"`
|
||||||
EnvironmentID uuid.UUID `json:"environment_id"`
|
EnvironmentID uuid.UUID `json:"environment_id"`
|
||||||
ServiceType string `json:"service_type"`
|
ServiceType string `json:"service_type"`
|
||||||
SourceType string `json:"source_type"`
|
SourceType string `json:"source_type"`
|
||||||
SourceUrl sql.NullString `json:"source_url"`
|
SourceUrl sql.NullString `json:"source_url"`
|
||||||
ImageName sql.NullString `json:"image_name"`
|
ImageName sql.NullString `json:"image_name"`
|
||||||
BuildCommand sql.NullString `json:"build_command"`
|
BuildCommand sql.NullString `json:"build_command"`
|
||||||
StartCommand sql.NullString `json:"start_command"`
|
StartCommand sql.NullString `json:"start_command"`
|
||||||
CpuLimit sql.NullInt32 `json:"cpu_limit"`
|
CpuLimit sql.NullInt32 `json:"cpu_limit"`
|
||||||
MemoryLimit sql.NullInt32 `json:"memory_limit"`
|
MemoryLimit sql.NullInt32 `json:"memory_limit"`
|
||||||
PublicUrl sql.NullString `json:"public_url"`
|
PublicUrl sql.NullString `json:"public_url"`
|
||||||
HealthCheckUrl sql.NullString `json:"health_check_url"`
|
HealthCheckUrl sql.NullString `json:"health_check_url"`
|
||||||
Status sql.NullString `json:"status"`
|
Status sql.NullString `json:"status"`
|
||||||
CreatedAt sql.NullTime `json:"created_at"`
|
CreatedAt sql.NullTime `json:"created_at"`
|
||||||
UpdatedAt sql.NullTime `json:"updated_at"`
|
UpdatedAt sql.NullTime `json:"updated_at"`
|
||||||
Type sql.NullString `json:"type"`
|
Type sql.NullString `json:"type"`
|
||||||
Image sql.NullString `json:"image"`
|
Image sql.NullString `json:"image"`
|
||||||
Command sql.NullString `json:"command"`
|
Command sql.NullString `json:"command"`
|
||||||
Environment sql.NullString `json:"environment"`
|
Environment sql.NullString `json:"environment"`
|
||||||
GitRepo sql.NullString `json:"git_repo"`
|
GitRepo sql.NullString `json:"git_repo"`
|
||||||
GitBranch sql.NullString `json:"git_branch"`
|
GitBranch sql.NullString `json:"git_branch"`
|
||||||
BuildPath sql.NullString `json:"build_path"`
|
BuildPath sql.NullString `json:"build_path"`
|
||||||
Cpu sql.NullString `json:"cpu"`
|
Cpu sql.NullString `json:"cpu"`
|
||||||
Memory sql.NullString `json:"memory"`
|
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 {
|
type ServiceTemplate struct {
|
||||||
@@ -211,3 +217,16 @@ type ServiceTemplate struct {
|
|||||||
CreatedAt sql.NullTime `json:"created_at"`
|
CreatedAt sql.NullTime `json:"created_at"`
|
||||||
UpdatedAt sql.NullTime `json:"updated_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)
|
CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error)
|
||||||
CreateServiceFromTemplate(ctx context.Context, arg CreateServiceFromTemplateParams) error
|
CreateServiceFromTemplate(ctx context.Context, arg CreateServiceFromTemplateParams) error
|
||||||
CreateUserTemplate(ctx context.Context, arg CreateUserTemplateParams) error
|
CreateUserTemplate(ctx context.Context, arg CreateUserTemplateParams) error
|
||||||
|
CreateUserToken(ctx context.Context, arg CreateUserTokenParams) (UserToken, error)
|
||||||
DatabaseServiceExistsByIDAndUser(ctx context.Context, arg DatabaseServiceExistsByIDAndUserParams) (bool, error)
|
DatabaseServiceExistsByIDAndUser(ctx context.Context, arg DatabaseServiceExistsByIDAndUserParams) (bool, error)
|
||||||
DeleteAgent(ctx context.Context, id string) error
|
DeleteAgent(ctx context.Context, id string) error
|
||||||
DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) 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)
|
GetProjectOwnerID(ctx context.Context, id uuid.UUID) (uuid.UUID, error)
|
||||||
GetProjectRoleForUser(ctx context.Context, arg GetProjectRoleForUserParams) (string, error)
|
GetProjectRoleForUser(ctx context.Context, arg GetProjectRoleForUserParams) (string, error)
|
||||||
GetServiceTemplateByID(ctx context.Context, id string) (ServiceTemplate, error)
|
GetServiceTemplateByID(ctx context.Context, id string) (ServiceTemplate, error)
|
||||||
|
GetUserTokenByHash(ctx context.Context, tokenHash string) (UserToken, error)
|
||||||
InsertAgentHeartbeat(ctx context.Context, arg InsertAgentHeartbeatParams) error
|
InsertAgentHeartbeat(ctx context.Context, arg InsertAgentHeartbeatParams) error
|
||||||
InsertProjectEnvironment(ctx context.Context, arg InsertProjectEnvironmentParams) error
|
InsertProjectEnvironment(ctx context.Context, arg InsertProjectEnvironmentParams) error
|
||||||
ListAgentAuthTokens(ctx context.Context) ([]AgentAuthToken, error)
|
ListAgentAuthTokens(ctx context.Context) ([]AgentAuthToken, error)
|
||||||
@@ -66,9 +68,11 @@ type Querier interface {
|
|||||||
ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error)
|
ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error)
|
||||||
ListServiceTemplatesByCategoryForUser(ctx context.Context, arg ListServiceTemplatesByCategoryForUserParams) ([]ServiceTemplate, error)
|
ListServiceTemplatesByCategoryForUser(ctx context.Context, arg ListServiceTemplatesByCategoryForUserParams) ([]ServiceTemplate, error)
|
||||||
ListServiceTemplatesForUser(ctx context.Context, ownerID uuid.NullUUID) ([]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
|
MarkAllNotificationsReadByUser(ctx context.Context, arg MarkAllNotificationsReadByUserParams) error
|
||||||
MarkNotificationReadByIDAndUser(ctx context.Context, arg MarkNotificationReadByIDAndUserParams) error
|
MarkNotificationReadByIDAndUser(ctx context.Context, arg MarkNotificationReadByIDAndUserParams) error
|
||||||
RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, 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
|
SetDatabaseBackupScheduleByIDAndUser(ctx context.Context, arg SetDatabaseBackupScheduleByIDAndUserParams) error
|
||||||
SetDatabaseBackupStatusByID(ctx context.Context, arg SetDatabaseBackupStatusByIDParams) error
|
SetDatabaseBackupStatusByID(ctx context.Context, arg SetDatabaseBackupStatusByIDParams) error
|
||||||
SetDatabaseNextBackupAt(ctx context.Context, arg SetDatabaseNextBackupAtParams) error
|
SetDatabaseNextBackupAt(ctx context.Context, arg SetDatabaseNextBackupAtParams) error
|
||||||
@@ -77,6 +81,7 @@ type Querier interface {
|
|||||||
SetDatabaseServiceStatusByIDAndUser(ctx context.Context, arg SetDatabaseServiceStatusByIDAndUserParams) error
|
SetDatabaseServiceStatusByIDAndUser(ctx context.Context, arg SetDatabaseServiceStatusByIDAndUserParams) error
|
||||||
SetProjectApproved(ctx context.Context, arg SetProjectApprovedParams) (int64, error)
|
SetProjectApproved(ctx context.Context, arg SetProjectApprovedParams) (int64, error)
|
||||||
TouchAgentAuthToken(ctx context.Context, tokenHash string) error
|
TouchAgentAuthToken(ctx context.Context, tokenHash string) error
|
||||||
|
TouchUserToken(ctx context.Context, id uuid.UUID) error
|
||||||
UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeAgent, error)
|
UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeAgent, error)
|
||||||
UpdateAgentHeartbeat(ctx context.Context, arg UpdateAgentHeartbeatParams) error
|
UpdateAgentHeartbeat(ctx context.Context, arg UpdateAgentHeartbeatParams) error
|
||||||
UpdateContainerStatus(ctx context.Context, arg UpdateContainerStatusParams) 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 (
|
import (
|
||||||
"containr/internal/database"
|
"containr/internal/database"
|
||||||
|
sqlcdb "containr/internal/database/sqlcdb"
|
||||||
|
"crypto/sha256"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"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) {
|
func resolveRequestUser(c *gin.Context, jwtSecret string, sessionVerifier *betterAuthSessionVerifier) (userID, email, tokenErr string, hasToken, ok bool) {
|
||||||
tokenString, tokenErr, hasToken := extractJWTToken(c)
|
tokenString, tokenErr, hasToken := extractJWTToken(c)
|
||||||
if tokenString != "" {
|
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 {
|
if claims, valid := validateJWTClaims(tokenString, jwtSecret); valid {
|
||||||
userIDClaim, exists := claims["user_id"]
|
userIDClaim, exists := claims["user_id"]
|
||||||
if exists {
|
if exists {
|
||||||
@@ -101,12 +112,67 @@ func resolveRequestUser(c *gin.Context, jwtSecret string, sessionVerifier *bette
|
|||||||
return "", "", tokenErr, hasToken, false
|
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
|
// setAuthenticatedContext stores the resolved identity plus the admin flag on
|
||||||
// the gin context so handlers and RequireAdmin can rely on it.
|
// the gin context so handlers and RequireAdmin can rely on it.
|
||||||
func setAuthenticatedContext(c *gin.Context, userID, email string) {
|
func setAuthenticatedContext(c *gin.Context, userID, email string) {
|
||||||
c.Set("user_id", userID)
|
c.Set("user_id", userID)
|
||||||
c.Set("email", email)
|
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 {
|
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)
|
userID, email, tokenErr, hasToken, ok := resolveRequestUser(c, jwtSecret, sessionVerifier)
|
||||||
if ok {
|
if ok {
|
||||||
setAuthenticatedContext(c, userID, email)
|
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()
|
c.Next()
|
||||||
return
|
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 {
|
func issueJWT(t *testing.T, secret string, claims jwt.MapClaims) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
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,
|
read_at TIMESTAMP WITH TIME ZONE,
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
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()
|
||||||
|
);
|
||||||
@@ -17,6 +17,9 @@ import {
|
|||||||
createAgentToken,
|
createAgentToken,
|
||||||
listAgentTokens,
|
listAgentTokens,
|
||||||
revokeAgentToken,
|
revokeAgentToken,
|
||||||
|
createUserToken,
|
||||||
|
listUserTokens,
|
||||||
|
revokeUserToken,
|
||||||
listDatabases,
|
listDatabases,
|
||||||
databaseAction,
|
databaseAction,
|
||||||
updateDatabaseBackupSchedule,
|
updateDatabaseBackupSchedule,
|
||||||
@@ -46,6 +49,8 @@ import {
|
|||||||
updateVulnerability,
|
updateVulnerability,
|
||||||
getSecurityMetrics,
|
getSecurityMetrics,
|
||||||
type AgentAuthTokenCreated,
|
type AgentAuthTokenCreated,
|
||||||
|
type UserTokenCreated,
|
||||||
|
type UserTokenScope,
|
||||||
type CreateDatabaseInput,
|
type CreateDatabaseInput,
|
||||||
type DatabaseEntity,
|
type DatabaseEntity,
|
||||||
type FailoverPolicy,
|
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<UserTokenScope>('write');
|
||||||
|
const [expiryDays, setExpiryDays] = useState(0);
|
||||||
|
const [issuedToken, setIssuedToken] = useState<UserTokenCreated | null>(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 (
|
||||||
|
<section className="panel p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
|
||||||
|
<Key size={18} className="text-[var(--accent-primary)]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Personal Access Tokens</h2>
|
||||||
|
<p className="text-xs text-[var(--text-tertiary)]">Authenticate the CLI, MCP server, and scripts - same rights as you, bounded by scope</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<input
|
||||||
|
value={tokenName}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={tokenScope}
|
||||||
|
onChange={(e) => setTokenScope(e.target.value as UserTokenScope)}
|
||||||
|
className="h-9 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] focus:border-[var(--accent-primary)] transition-all"
|
||||||
|
>
|
||||||
|
{scopeOptions.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>{option.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={expiryDays}
|
||||||
|
onChange={(e) => setExpiryDays(Number(e.target.value))}
|
||||||
|
className="h-9 rounded-[var(--radius-md)] border border-[var(--border-subtle)] bg-[var(--surface-muted)] px-3 text-sm text-[var(--text-primary)] focus:border-[var(--accent-primary)] transition-all"
|
||||||
|
>
|
||||||
|
{PAT_EXPIRY_OPTIONS.map((option) => (
|
||||||
|
<option key={option.days} value={option.days}>{option.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={() => createMutation.mutate()}
|
||||||
|
disabled={createMutation.isPending || !tokenName.trim()}
|
||||||
|
className="h-9 px-4 rounded-[var(--radius-md)] text-sm font-medium text-[var(--accent-on)] disabled:opacity-50 disabled:cursor-not-allowed transition-all"
|
||||||
|
style={{ background: 'var(--accent-primary)' }}
|
||||||
|
>
|
||||||
|
{createMutation.isPending ? 'Creating…' : 'Create token'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{issuedToken ? (
|
||||||
|
<div className="rounded-[var(--radius-md)] border border-[var(--success)]/30 bg-[var(--success-soft)] p-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="mono text-xs text-[var(--text-primary)] break-all">{issuedToken.token}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => void navigator.clipboard.writeText(issuedToken.token ?? '')}
|
||||||
|
className="flex items-center gap-1 text-xs text-[var(--accent-primary)] hover:underline shrink-0"
|
||||||
|
>
|
||||||
|
<Copy size={11} />
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-[var(--text-tertiary)]">
|
||||||
|
Shown once - store it now. Use with <span className="mono">containr auth login --token</span> or as a Bearer token.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{createMutation.isError ? (
|
||||||
|
<p className="text-xs text-[var(--error)]">
|
||||||
|
{createMutation.error instanceof Error ? createMutation.error.message : 'Failed to create token'}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{tokensQuery.isLoading ? (
|
||||||
|
<div className="py-4 text-center">
|
||||||
|
<Loader2 size={16} className="animate-spin mx-auto text-[var(--text-tertiary)]" />
|
||||||
|
</div>
|
||||||
|
) : tokens.length === 0 ? (
|
||||||
|
<p className="text-xs text-[var(--text-tertiary)]">No tokens yet. Create one to connect the CLI or an agent.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5 border-t border-[var(--border-subtle)] pt-3">
|
||||||
|
{tokens.map((token) => (
|
||||||
|
<div key={token.id} className="flex items-center justify-between gap-2 text-xs">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<span className="text-[var(--text-primary)]">{token.name}</span>
|
||||||
|
<span className="ml-2 mono text-[var(--text-muted)]">{token.key_prefix}…</span>
|
||||||
|
<span className="ml-2 rounded px-1.5 py-0.5 bg-[var(--surface-muted)] text-[var(--text-secondary)]">{token.scope}</span>
|
||||||
|
<span className="ml-2 text-[var(--text-muted)]">
|
||||||
|
{token.last_used_at ? `used ${formatRelative(token.last_used_at)}` : 'unused'}
|
||||||
|
{token.expires_at ? ` · expires ${formatRelative(token.expires_at)}` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (window.confirm(`Revoke token "${token.name}"? Clients using it lose access immediately.`)) {
|
||||||
|
revokeMutation.mutate(token.id ?? '');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={revokeMutation.isPending}
|
||||||
|
className="text-[var(--error)] hover:underline disabled:opacity-50 shrink-0"
|
||||||
|
>
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -1438,6 +1601,9 @@ export function SettingsPage() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Personal Access Tokens */}
|
||||||
|
<UserTokensSection isAdmin={profileQuery.data?.isAdmin ?? false} />
|
||||||
|
|
||||||
{/* Platform Settings - owner only */}
|
{/* Platform Settings - owner only */}
|
||||||
{profileQuery.data?.isAdmin ? <PlatformSettingsSection /> : null}
|
{profileQuery.data?.isAdmin ? <PlatformSettingsSection /> : null}
|
||||||
|
|
||||||
|
|||||||
@@ -231,6 +231,153 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/projects": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -8476,6 +8623,37 @@ export interface components {
|
|||||||
/** @description Human-readable label for the token (e.g. node name) */
|
/** @description Human-readable label for the token (e.g. node name) */
|
||||||
label?: string;
|
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: {
|
AgentCapabilities: {
|
||||||
container_runtimes?: string[];
|
container_runtimes?: string[];
|
||||||
supported_architectures?: string[];
|
supported_architectures?: string[];
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export const bundledDocs: BundledDoc[] = [
|
|||||||
"path": "guides/OPERATIONS.md",
|
"path": "guides/OPERATIONS.md",
|
||||||
"section": "guides",
|
"section": "guides",
|
||||||
"title": "Operations",
|
"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 <command>`) 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 <command>`) 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",
|
"path": "guides/README_BACKEND.md",
|
||||||
|
|||||||
@@ -993,6 +993,34 @@ export async function revokeAgentToken(id: string): Promise<void> {
|
|||||||
await requestJson(`/agent-tokens/${id}`, { method: 'DELETE' });
|
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<UserTokenCreated> {
|
||||||
|
return requestJson<UserTokenCreated>(`/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<UserToken[]> {
|
||||||
|
const payload = await requestJson<{ tokens?: UserToken[] }>(`/user/tokens`);
|
||||||
|
return payload.tokens ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeUserToken(id: string): Promise<void> {
|
||||||
|
await requestJson(`/user/tokens/${id}`, { method: 'DELETE' });
|
||||||
|
}
|
||||||
|
|
||||||
export async function createProject(input: CreateProjectInput): Promise<ProjectEntity> {
|
export async function createProject(input: CreateProjectInput): Promise<ProjectEntity> {
|
||||||
const payload = await requestJson<RawProject | { project?: RawProject }>(`/projects`, {
|
const payload = await requestJson<RawProject | { project?: RawProject }>(`/projects`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -152,6 +152,97 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/Error'
|
$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
|
||||||
/projects:
|
/projects:
|
||||||
get:
|
get:
|
||||||
@@ -6395,6 +6486,66 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
description: Human-readable label for the token (e.g. node name)
|
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:
|
AgentCapabilities:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
|||||||
@@ -90,3 +90,30 @@ backend restart.
|
|||||||
once), copy the prefilled install command, revoke tokens when done. Agents
|
once), copy the prefilled install command, revoke tokens when done. Agents
|
||||||
register over `POST /api/agents/register` and heartbeat via
|
register over `POST /api/agents/register` and heartbeat via
|
||||||
`POST /api/agents/heartbeat`.
|
`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.
|
||||||
Reference in new issue
Block a user