Add agent onboarding tokens with Usage-page issuance UI

Per-node onboarding tokens replace the placeholder <token> in the agent
connect command: raw cagt_ tokens are returned once at issuance, only
sha256 hashes persist in agent_auth_tokens, and listing exposes metadata
only. Public agent routes accept env-configured shared tokens or any
active DB-issued token; successful validation stamps last_used_at and
revoked tokens are rejected — verified end-to-end against a real stack
(register, heartbeat, revoke → 401, bogus → 401).

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-17 16:46:26 +02:00
1 parent 2bfa683084
commit aab785afc1
12 files changed
+700 -19

No files matched your search

+132 -12
View File
@@ -2,9 +2,13 @@ package api
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"math"
"net/http"
@@ -331,7 +335,7 @@ func (h *NodeAgentHandler) RegisterAgent(c *gin.Context) {
}
authToken := firstNonEmpty(req.AuthToken, agentAuthTokenFromRequest(c))
if !isValidAgentAuthToken(authToken) {
if !h.isValidAgentAuthToken(c.Request.Context(), authToken) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid auth token"})
return
}
@@ -532,7 +536,7 @@ func (h *NodeAgentHandler) SendHeartbeat(c *gin.Context) {
return
}
authToken := firstNonEmpty(heartbeat.AuthToken, agentAuthTokenFromRequest(c))
if !isValidAgentAuthToken(authToken) {
if !h.isValidAgentAuthToken(c.Request.Context(), authToken) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid auth token"})
return
}
@@ -582,7 +586,7 @@ func (h *NodeAgentHandler) SendHeartbeat(c *gin.Context) {
// GetPendingCommandsForAgent exposes queued commands to token-authenticated node agents.
func (h *NodeAgentHandler) GetPendingCommandsForAgent(c *gin.Context) {
if !isValidAgentAuthToken(agentAuthTokenFromRequest(c)) {
if !h.isValidAgentAuthToken(c.Request.Context(), agentAuthTokenFromRequest(c)) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid auth token"})
return
}
@@ -603,7 +607,7 @@ func (h *NodeAgentHandler) GetPendingCommandsForAgent(c *gin.Context) {
// CompleteCommand lets a node agent report command completion or failure.
func (h *NodeAgentHandler) CompleteCommand(c *gin.Context) {
if !isValidAgentAuthToken(agentAuthTokenFromRequest(c)) {
if !h.isValidAgentAuthToken(c.Request.Context(), agentAuthTokenFromRequest(c)) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid auth token"})
return
}
@@ -1013,22 +1017,47 @@ func buildMetricPoint(ts time.Time, resources NodeResources, load SystemLoad, co
}
func isValidAgentAuthToken(token string) bool {
candidates := configuredAgentAuthTokens()
if len(candidates) == 0 {
token = strings.TrimSpace(token)
if token == "" {
return false
}
for _, candidate := range configuredAgentAuthTokens() {
if subtle.ConstantTimeCompare([]byte(token), []byte(candidate)) == 1 {
return true
}
}
return false
}
// isValidAgentAuthTokenDB accepts env-configured shared tokens or a
// DB-issued onboarding token (matched by sha256 hash, never stored raw).
func (h *NodeAgentHandler) isValidAgentAuthToken(ctx context.Context, token string) bool {
if isValidAgentAuthToken(token) {
return true
}
token = strings.TrimSpace(token)
if token == "" {
return false
}
for _, candidate := range candidates {
if subtle.ConstantTimeCompare([]byte(token), []byte(candidate)) == 1 {
return true
}
sum := sha256.Sum256([]byte(token))
hash := hex.EncodeToString(sum[:])
if _, err := h.q.GetActiveAgentAuthTokenByHash(ctx, hash); err != nil {
return false
}
return false
_ = h.q.TouchAgentAuthToken(ctx, hash)
return true
}
func generateAgentAuthToken() (token string, hash string, err error) {
buf := make([]byte, 24)
if _, err = rand.Read(buf); err != nil {
return "", "", err
}
token = "cagt_" + hex.EncodeToString(buf)
sum := sha256.Sum256([]byte(token))
return token, hex.EncodeToString(sum[:]), nil
}
func agentAuthTokenFromRequest(c *gin.Context) string {
@@ -1093,6 +1122,90 @@ func maxInt(a, b int) int {
return b
}
// --- onboarding token management ---
func agentAuthTokenJSON(t sqlcdb.AgentAuthToken) gin.H {
row := gin.H{
"id": t.ID,
"label": t.Label,
"revoked": t.RevokedAt.Valid,
}
if t.CreatedAt.Valid {
row["created_at"] = t.CreatedAt.Time
}
if t.LastUsedAt.Valid {
row["last_used_at"] = t.LastUsedAt.Time
}
if t.RevokedAt.Valid {
row["revoked_at"] = t.RevokedAt.Time
}
return row
}
// CreateAgentToken issues a new onboarding token. The raw token is returned
// once and never stored — only its sha256 hash persists.
func (h *NodeAgentHandler) CreateAgentToken(c *gin.Context) {
var req struct {
Label string `json:"label"`
}
_ = c.ShouldBindJSON(&req)
token, hash, err := generateAgentAuthToken()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate token"})
return
}
row, err := h.q.CreateAgentAuthToken(c.Request.Context(), sqlcdb.CreateAgentAuthTokenParams{
TokenHash: hash,
Label: strings.TrimSpace(req.Label),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to store token"})
return
}
payload := agentAuthTokenJSON(row)
payload["token"] = token
c.JSON(http.StatusCreated, payload)
}
// ListAgentTokens returns issued onboarding tokens (metadata only, never hashes).
func (h *NodeAgentHandler) ListAgentTokens(c *gin.Context) {
rows, err := h.q.ListAgentAuthTokens(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch tokens"})
return
}
tokens := make([]gin.H, 0, len(rows))
for _, row := range rows {
tokens = append(tokens, agentAuthTokenJSON(row))
}
c.JSON(http.StatusOK, gin.H{"tokens": tokens})
}
// RevokeAgentToken marks an onboarding token as revoked; agents presenting it
// are rejected from then on.
func (h *NodeAgentHandler) RevokeAgentToken(c *gin.Context) {
id, err := uuid.Parse(strings.TrimSpace(c.Param("id")))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid token id"})
return
}
row, err := h.q.RevokeAgentAuthToken(c.Request.Context(), id)
if err != nil {
status := http.StatusInternalServerError
if errors.Is(err, sql.ErrNoRows) {
status = http.StatusNotFound
}
c.JSON(status, gin.H{"error": "Token not found"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "revoked", "id": row.ID})
}
// SetupPublicRoutes registers token-authenticated agent ingestion routes.
func (h *NodeAgentHandler) SetupPublicRoutes(router *gin.RouterGroup) {
agents := router.Group("/agents")
@@ -1106,6 +1219,13 @@ func (h *NodeAgentHandler) SetupPublicRoutes(router *gin.RouterGroup) {
// SetupRoutes registers authenticated dashboard/control agent routes.
func (h *NodeAgentHandler) SetupRoutes(router *gin.RouterGroup) {
tokens := router.Group("/agent-tokens")
{
tokens.POST("", h.CreateAgentToken)
tokens.GET("", h.ListAgentTokens)
tokens.DELETE("/:id", h.RevokeAgentToken)
}
agents := router.Group("/agents")
{
agents.GET("", h.GetAgents)
@@ -0,0 +1,122 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: agent_tokens.sql
package sqlcdb
import (
"context"
"github.com/google/uuid"
)
const createAgentAuthToken = `-- name: CreateAgentAuthToken :one
INSERT INTO agent_auth_tokens (token_hash, label)
VALUES ($1, $2)
RETURNING id, token_hash, label, created_at, last_used_at, revoked_at
`
type CreateAgentAuthTokenParams struct {
TokenHash string `json:"token_hash"`
Label string `json:"label"`
}
func (q *Queries) CreateAgentAuthToken(ctx context.Context, arg CreateAgentAuthTokenParams) (AgentAuthToken, error) {
row := q.db.QueryRowContext(ctx, createAgentAuthToken, arg.TokenHash, arg.Label)
var i AgentAuthToken
err := row.Scan(
&i.ID,
&i.TokenHash,
&i.Label,
&i.CreatedAt,
&i.LastUsedAt,
&i.RevokedAt,
)
return i, err
}
const getActiveAgentAuthTokenByHash = `-- name: GetActiveAgentAuthTokenByHash :one
SELECT id, token_hash, label, created_at, last_used_at, revoked_at FROM agent_auth_tokens
WHERE token_hash = $1 AND revoked_at IS NULL
LIMIT 1
`
func (q *Queries) GetActiveAgentAuthTokenByHash(ctx context.Context, tokenHash string) (AgentAuthToken, error) {
row := q.db.QueryRowContext(ctx, getActiveAgentAuthTokenByHash, tokenHash)
var i AgentAuthToken
err := row.Scan(
&i.ID,
&i.TokenHash,
&i.Label,
&i.CreatedAt,
&i.LastUsedAt,
&i.RevokedAt,
)
return i, err
}
const listAgentAuthTokens = `-- name: ListAgentAuthTokens :many
SELECT id, token_hash, label, created_at, last_used_at, revoked_at FROM agent_auth_tokens
ORDER BY created_at DESC
`
func (q *Queries) ListAgentAuthTokens(ctx context.Context) ([]AgentAuthToken, error) {
rows, err := q.db.QueryContext(ctx, listAgentAuthTokens)
if err != nil {
return nil, err
}
defer rows.Close()
items := []AgentAuthToken{}
for rows.Next() {
var i AgentAuthToken
if err := rows.Scan(
&i.ID,
&i.TokenHash,
&i.Label,
&i.CreatedAt,
&i.LastUsedAt,
&i.RevokedAt,
); 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 revokeAgentAuthToken = `-- name: RevokeAgentAuthToken :one
UPDATE agent_auth_tokens SET revoked_at = NOW()
WHERE id = $1 AND revoked_at IS NULL
RETURNING id, token_hash, label, created_at, last_used_at, revoked_at
`
func (q *Queries) RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error) {
row := q.db.QueryRowContext(ctx, revokeAgentAuthToken, id)
var i AgentAuthToken
err := row.Scan(
&i.ID,
&i.TokenHash,
&i.Label,
&i.CreatedAt,
&i.LastUsedAt,
&i.RevokedAt,
)
return i, err
}
const touchAgentAuthToken = `-- name: TouchAgentAuthToken :exec
UPDATE agent_auth_tokens SET last_used_at = NOW()
WHERE token_hash = $1 AND revoked_at IS NULL
`
func (q *Queries) TouchAgentAuthToken(ctx context.Context, tokenHash string) error {
_, err := q.db.ExecContext(ctx, touchAgentAuthToken, tokenHash)
return err
}
@@ -13,6 +13,15 @@ import (
"github.com/sqlc-dev/pqtype"
)
type AgentAuthToken struct {
ID uuid.UUID `json:"id"`
TokenHash string `json:"token_hash"`
Label string `json:"label"`
CreatedAt sql.NullTime `json:"created_at"`
LastUsedAt sql.NullTime `json:"last_used_at"`
RevokedAt sql.NullTime `json:"revoked_at"`
}
type AgentCommand struct {
ID string `json:"id"`
Type string `json:"type"`
@@ -16,6 +16,7 @@ type Querier interface {
CountProjectsByUser(ctx context.Context, arg CountProjectsByUserParams) (int64, error)
CountServicesByProjectAndName(ctx context.Context, arg CountServicesByProjectAndNameParams) (int64, error)
CreateAgent(ctx context.Context, arg CreateAgentParams) (NodeAgent, error)
CreateAgentAuthToken(ctx context.Context, arg CreateAgentAuthTokenParams) (AgentAuthToken, error)
CreateCommand(ctx context.Context, arg CreateCommandParams) (AgentCommand, error)
CreateContainer(ctx context.Context, arg CreateContainerParams) (ContainerInstance, error)
CreateDatabaseBackup(ctx context.Context, arg CreateDatabaseBackupParams) error
@@ -26,6 +27,7 @@ type Querier interface {
DeleteAgent(ctx context.Context, id string) error
DeleteDatabaseServiceByIDAndUser(ctx context.Context, arg DeleteDatabaseServiceByIDAndUserParams) error
DeleteProjectByID(ctx context.Context, projectID uuid.UUID) (int64, error)
GetActiveAgentAuthTokenByHash(ctx context.Context, tokenHash string) (AgentAuthToken, error)
GetAgent(ctx context.Context, id string) (NodeAgent, error)
GetAgentByHostAndIP(ctx context.Context, arg GetAgentByHostAndIPParams) (NodeAgent, error)
GetCommandForAgent(ctx context.Context, arg GetCommandForAgentParams) (AgentCommand, error)
@@ -40,6 +42,7 @@ type Querier interface {
GetServiceTemplateByID(ctx context.Context, id string) (ServiceTemplate, error)
InsertAgentHeartbeat(ctx context.Context, arg InsertAgentHeartbeatParams) error
InsertProjectEnvironment(ctx context.Context, arg InsertProjectEnvironmentParams) error
ListAgentAuthTokens(ctx context.Context) ([]AgentAuthToken, error)
ListAgentHeartbeatsSince(ctx context.Context, arg ListAgentHeartbeatsSinceParams) ([]AgentHeartbeat, error)
ListAgents(ctx context.Context) ([]NodeAgent, error)
ListCommandsForAgent(ctx context.Context, nodeAgentID string) ([]AgentCommand, error)
@@ -50,10 +53,12 @@ type Querier interface {
ListProjectsWithStatsByUser(ctx context.Context, arg ListProjectsWithStatsByUserParams) ([]ListProjectsWithStatsByUserRow, error)
ListServiceTemplates(ctx context.Context) ([]ServiceTemplate, error)
ListServiceTemplatesByCategory(ctx context.Context, category string) ([]ServiceTemplate, error)
RevokeAgentAuthToken(ctx context.Context, id uuid.UUID) (AgentAuthToken, error)
SetDatabaseBackupStatusByID(ctx context.Context, arg SetDatabaseBackupStatusByIDParams) error
SetDatabaseServiceStatusAndConnectionByID(ctx context.Context, arg SetDatabaseServiceStatusAndConnectionByIDParams) error
SetDatabaseServiceStatusByID(ctx context.Context, arg SetDatabaseServiceStatusByIDParams) error
SetDatabaseServiceStatusByIDAndUser(ctx context.Context, arg SetDatabaseServiceStatusByIDAndUserParams) error
TouchAgentAuthToken(ctx context.Context, tokenHash string) error
UpdateAgent(ctx context.Context, arg UpdateAgentParams) (NodeAgent, error)
UpdateAgentHeartbeat(ctx context.Context, arg UpdateAgentHeartbeatParams) error
UpdateContainerStatus(ctx context.Context, arg UpdateContainerStatusParams) error
@@ -0,0 +1,20 @@
-- +goose Up
-- Per-agent onboarding tokens. Hash (sha256 hex) is stored, never the raw token;
-- the raw value is shown once at creation time.
CREATE TABLE IF NOT EXISTS public.agent_auth_tokens (
id uuid DEFAULT gen_random_uuid() NOT NULL,
token_hash text NOT NULL,
label character varying(255) DEFAULT '' NOT NULL,
created_at timestamp with time zone DEFAULT now(),
last_used_at timestamp with time zone,
revoked_at timestamp with time zone,
CONSTRAINT agent_auth_tokens_pkey PRIMARY KEY (id),
CONSTRAINT agent_auth_tokens_token_hash_key UNIQUE (token_hash)
);
CREATE INDEX IF NOT EXISTS idx_agent_auth_tokens_active
ON public.agent_auth_tokens (token_hash)
WHERE revoked_at IS NULL;
-- +goose Down
DROP TABLE IF EXISTS public.agent_auth_tokens;
+22
View File
@@ -0,0 +1,22 @@
-- name: CreateAgentAuthToken :one
INSERT INTO agent_auth_tokens (token_hash, label)
VALUES ($1, $2)
RETURNING *;
-- name: ListAgentAuthTokens :many
SELECT * FROM agent_auth_tokens
ORDER BY created_at DESC;
-- name: GetActiveAgentAuthTokenByHash :one
SELECT * FROM agent_auth_tokens
WHERE token_hash = $1 AND revoked_at IS NULL
LIMIT 1;
-- name: TouchAgentAuthToken :exec
UPDATE agent_auth_tokens SET last_used_at = NOW()
WHERE token_hash = $1 AND revoked_at IS NULL;
-- name: RevokeAgentAuthToken :one
UPDATE agent_auth_tokens SET revoked_at = NOW()
WHERE id = $1 AND revoked_at IS NULL
RETURNING *;
+9
View File
@@ -160,6 +160,15 @@ CREATE TABLE agent_commands (
completed_at TIMESTAMP WITH TIME ZONE
);
CREATE TABLE agent_auth_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
token_hash TEXT NOT NULL UNIQUE,
label VARCHAR(255) NOT NULL DEFAULT '',
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
last_used_at TIMESTAMP WITH TIME ZONE,
revoked_at TIMESTAMP WITH TIME ZONE
);
CREATE TABLE agent_heartbeats (
id VARCHAR(255) PRIMARY KEY,
node_agent_id VARCHAR(255) NOT NULL REFERENCES node_agents(id) ON DELETE CASCADE,