feat: encrypted secrets vault across CLI, desktop, dashboard, and backend

Local vault (apps/cli): clean-room PRMVLT01 format — Argon2id →
XChaCha20-Poly1305, AEAD-bound header, atomic writes. vault:* / secrets:*
/ inject (primora:// refs) / agent credential broker (dummy tokens in the
child, real secret on the wire) / TTL sessions / stack:* operator group.

Project vault (backend): core.project_secrets, AES-256-GCM via the
platform encryptor. Metadata-only list, audit-logged reveal, and
secret://NAME refs resolved at job delivery — refs stored, values never.

Surfaces: dashboard Vault page (+ local-vault card under the Tauri
shell), desktop vault window via IPC to the primora binary,
secrets:* --remote CLI mode, metadata/write-only MCP tools so agents
consume secrets via secret:// refs without holding plaintext.

Authored By: TDvorak <[email protected]>

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-24 18:46:37 +02:00
1 parent 31bc23386b
commit 585bb04ab2
52 files changed
+3929 -142

No files matched your search

@@ -0,0 +1,19 @@
-- +goose Up
-- Project-level secrets: AES-256-GCM ciphertext produced by the backend's
-- Encryptor. Values are write-once — list/read expose metadata only; a
-- dedicated reveal endpoint decrypts and is audit-logged. secret://NAME
-- references in job payloads resolve at delivery time.
CREATE TABLE core.project_secrets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
project_id UUID NOT NULL REFERENCES core.projects(id) ON DELETE CASCADE,
name TEXT NOT NULL,
ciphertext BYTEA NOT NULL,
url TEXT NOT NULL DEFAULT '',
notes TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (project_id, name)
);
-- +goose Down
DROP TABLE IF EXISTS core.project_secrets;
@@ -0,0 +1,36 @@
-- name: ListProjectSecrets :many
SELECT id, project_id, name, url, notes, created_at, updated_at
FROM core.project_secrets
WHERE project_id = $1
ORDER BY name ASC;
-- name: GetProjectSecret :one
SELECT * FROM core.project_secrets
WHERE project_id = $1
AND name = $2;
-- name: UpsertProjectSecret :one
INSERT INTO core.project_secrets (
project_id,
name,
ciphertext,
url,
notes
) VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (project_id, name) DO UPDATE SET
ciphertext = EXCLUDED.ciphertext,
url = EXCLUDED.url,
notes = EXCLUDED.notes,
updated_at = NOW()
RETURNING *;
-- name: DeleteProjectSecret :one
DELETE FROM core.project_secrets
WHERE project_id = $1
AND name = $2
RETURNING id;
-- name: ListProjectSecretValues :many
SELECT name, ciphertext
FROM core.project_secrets
WHERE project_id = $1;
@@ -307,6 +307,17 @@ type CoreProjectMember struct {
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
type CoreProjectSecret struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Ciphertext []byte `json:"ciphertext"`
Url string `json:"url"`
Notes string `json:"notes"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
type CoreScheduledJob struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
@@ -0,0 +1,181 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.29.0
// source: project_secrets.sql
package db
import (
"context"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
const deleteProjectSecret = `-- name: DeleteProjectSecret :one
DELETE FROM core.project_secrets
WHERE project_id = $1
AND name = $2
RETURNING id
`
type DeleteProjectSecretParams struct {
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
}
func (q *Queries) DeleteProjectSecret(ctx context.Context, arg DeleteProjectSecretParams) (uuid.UUID, error) {
row := q.db.QueryRow(ctx, deleteProjectSecret, arg.ProjectID, arg.Name)
var id uuid.UUID
err := row.Scan(&id)
return id, err
}
const getProjectSecret = `-- name: GetProjectSecret :one
SELECT id, project_id, name, ciphertext, url, notes, created_at, updated_at FROM core.project_secrets
WHERE project_id = $1
AND name = $2
`
type GetProjectSecretParams struct {
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
}
func (q *Queries) GetProjectSecret(ctx context.Context, arg GetProjectSecretParams) (CoreProjectSecret, error) {
row := q.db.QueryRow(ctx, getProjectSecret, arg.ProjectID, arg.Name)
var i CoreProjectSecret
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Ciphertext,
&i.Url,
&i.Notes,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const listProjectSecretValues = `-- name: ListProjectSecretValues :many
SELECT name, ciphertext
FROM core.project_secrets
WHERE project_id = $1
`
type ListProjectSecretValuesRow struct {
Name string `json:"name"`
Ciphertext []byte `json:"ciphertext"`
}
func (q *Queries) ListProjectSecretValues(ctx context.Context, projectID uuid.UUID) ([]ListProjectSecretValuesRow, error) {
rows, err := q.db.Query(ctx, listProjectSecretValues, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListProjectSecretValuesRow
for rows.Next() {
var i ListProjectSecretValuesRow
if err := rows.Scan(&i.Name, &i.Ciphertext); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listProjectSecrets = `-- name: ListProjectSecrets :many
SELECT id, project_id, name, url, notes, created_at, updated_at
FROM core.project_secrets
WHERE project_id = $1
ORDER BY name ASC
`
type ListProjectSecretsRow struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Url string `json:"url"`
Notes string `json:"notes"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
}
func (q *Queries) ListProjectSecrets(ctx context.Context, projectID uuid.UUID) ([]ListProjectSecretsRow, error) {
rows, err := q.db.Query(ctx, listProjectSecrets, projectID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListProjectSecretsRow
for rows.Next() {
var i ListProjectSecretsRow
if err := rows.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Url,
&i.Notes,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const upsertProjectSecret = `-- name: UpsertProjectSecret :one
INSERT INTO core.project_secrets (
project_id,
name,
ciphertext,
url,
notes
) VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (project_id, name) DO UPDATE SET
ciphertext = EXCLUDED.ciphertext,
url = EXCLUDED.url,
notes = EXCLUDED.notes,
updated_at = NOW()
RETURNING id, project_id, name, ciphertext, url, notes, created_at, updated_at
`
type UpsertProjectSecretParams struct {
ProjectID uuid.UUID `json:"project_id"`
Name string `json:"name"`
Ciphertext []byte `json:"ciphertext"`
Url string `json:"url"`
Notes string `json:"notes"`
}
func (q *Queries) UpsertProjectSecret(ctx context.Context, arg UpsertProjectSecretParams) (CoreProjectSecret, error) {
row := q.db.QueryRow(ctx, upsertProjectSecret,
arg.ProjectID,
arg.Name,
arg.Ciphertext,
arg.Url,
arg.Notes,
)
var i CoreProjectSecret
err := row.Scan(
&i.ID,
&i.ProjectID,
&i.Name,
&i.Ciphertext,
&i.Url,
&i.Notes,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
@@ -45,6 +45,7 @@ type Querier interface {
DeleteOrganizationByID(ctx context.Context, id uuid.UUID) (CoreOrganization, error)
DeletePendingInvitationByIDForOrganization(ctx context.Context, arg DeletePendingInvitationByIDForOrganizationParams) (CoreProjectInvitation, error)
DeleteProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, error)
DeleteProjectSecret(ctx context.Context, arg DeleteProjectSecretParams) (uuid.UUID, error)
DeleteScheduledJob(ctx context.Context, arg DeleteScheduledJobParams) (uuid.UUID, error)
DeleteSetting(ctx context.Context, key string) error
DeleteWebhook(ctx context.Context, arg DeleteWebhookParams) (CoreWebhook, error)
@@ -68,6 +69,7 @@ type Querier interface {
GetProjectByID(ctx context.Context, id uuid.UUID) (CoreProject, error)
GetProjectMembership(ctx context.Context, arg GetProjectMembershipParams) (GetProjectMembershipRow, error)
GetProjectOverview(ctx context.Context, id uuid.UUID) (GetProjectOverviewRow, error)
GetProjectSecret(ctx context.Context, arg GetProjectSecretParams) (CoreProjectSecret, error)
GetScheduledJobByID(ctx context.Context, id uuid.UUID) (CoreScheduledJob, error)
GetSetting(ctx context.Context, key string) (GetSettingRow, error)
GetUserByAuthSubject(ctx context.Context, authSubject string) (CoreUser, error)
@@ -96,6 +98,8 @@ type Querier interface {
ListOrganizationsForUser(ctx context.Context, userID uuid.UUID) ([]ListOrganizationsForUserRow, error)
ListPendingWebhookDeliveries(ctx context.Context, limit int32) ([]CoreWebhookDelivery, error)
ListProjectMembers(ctx context.Context, projectID uuid.UUID) ([]ListProjectMembersRow, error)
ListProjectSecretValues(ctx context.Context, projectID uuid.UUID) ([]ListProjectSecretValuesRow, error)
ListProjectSecrets(ctx context.Context, projectID uuid.UUID) ([]ListProjectSecretsRow, error)
ListProjectsForOrganization(ctx context.Context, arg ListProjectsForOrganizationParams) ([]ListProjectsForOrganizationRow, error)
ListScheduledJobRuns(ctx context.Context, arg ListScheduledJobRunsParams) ([]CoreScheduledJobRun, error)
ListScheduledJobs(ctx context.Context, projectID uuid.UUID) ([]CoreScheduledJob, error)
@@ -135,6 +139,7 @@ type Querier interface {
UpdateScheduledJob(ctx context.Context, arg UpdateScheduledJobParams) (CoreScheduledJob, error)
UpdateWebhook(ctx context.Context, arg UpdateWebhookParams) (CoreWebhook, error)
UpsertComponent(ctx context.Context, arg UpsertComponentParams) (CoreComponent, error)
UpsertProjectSecret(ctx context.Context, arg UpsertProjectSecretParams) (CoreProjectSecret, error)
UpsertSetting(ctx context.Context, arg UpsertSettingParams) (CoreSetting, error)
UpsertUser(ctx context.Context, arg UpsertUserParams) (CoreUser, error)
}
+4
View File
@@ -116,6 +116,10 @@ func (h *HTTPHandler) Register(router *gin.Engine) {
api.DELETE("/projects/:projectID/jobs/:jobID", h.deleteJob)
api.GET("/projects/:projectID/jobs/:jobID/runs", h.listJobRuns)
api.POST("/projects/:projectID/jobs/:jobID/run", h.runJob)
api.GET("/projects/:projectID/secrets", h.listProjectSecrets)
api.PUT("/projects/:projectID/secrets/:name", h.setProjectSecret)
api.DELETE("/projects/:projectID/secrets/:name", h.deleteProjectSecret)
api.POST("/projects/:projectID/secrets/:name/reveal", h.revealProjectSecret)
api.GET("/projects/:projectID/realtime/stream", h.realtimeStream)
api.GET("/buckets/:bucketID/objects", h.listObjects)
api.POST("/buckets/:bucketID/objects", h.uploadObject)
+87
View File
@@ -0,0 +1,87 @@
package handlers
import (
"errors"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5"
"github.com/tdvorak/primora/apps/backend/internal/middleware"
apperrors "github.com/tdvorak/primora/apps/backend/internal/response"
"github.com/tdvorak/primora/apps/backend/internal/services"
)
func (h *HTTPHandler) secretError(c *gin.Context, err error) {
if errors.Is(err, pgx.ErrNoRows) {
apperrors.Abort(c, http.StatusNotFound, "not_found", "secret not found")
return
}
msg := err.Error()
if strings.Contains(msg, "invalid secret name") {
apperrors.Abort(c, http.StatusBadRequest, "bad_request", msg)
return
}
if strings.Contains(msg, "encryption is not configured") {
apperrors.Abort(c, http.StatusServiceUnavailable, "unavailable", msg)
return
}
h.handleError(c, err)
}
func (h *HTTPHandler) listProjectSecrets(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
result, err := h.Platform.ListProjectSecrets(c.Request.Context(), actor, projectID)
if err != nil {
h.secretError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"items": result})
}
func (h *HTTPHandler) setProjectSecret(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
var body services.SetProjectSecretInput
if !h.bindAndValidate(c, &body) {
return
}
body.Name = c.Param("name")
result, err := h.Platform.SetProjectSecret(c.Request.Context(), actor, projectID, body, middleware.RequestIDFromContext(c))
if err != nil {
h.secretError(c, err)
return
}
c.JSON(http.StatusOK, result)
}
func (h *HTTPHandler) deleteProjectSecret(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
if err := h.Platform.DeleteProjectSecret(c.Request.Context(), actor, projectID, c.Param("name"), middleware.RequestIDFromContext(c)); err != nil {
h.secretError(c, err)
return
}
c.Status(http.StatusNoContent)
}
func (h *HTTPHandler) revealProjectSecret(c *gin.Context) {
actor, projectID, ok := h.actorAndProject(c)
if !ok {
return
}
result, err := h.Platform.RevealProjectSecret(c.Request.Context(), actor, projectID, c.Param("name"), middleware.RequestIDFromContext(c))
if err != nil {
h.secretError(c, err)
return
}
c.JSON(http.StatusOK, result)
}
+6 -1
View File
@@ -393,6 +393,7 @@ type jobQueries interface {
FinishScheduledJobRun(ctx context.Context, params db.FinishScheduledJobRunParams) (db.CoreScheduledJobRun, error)
MarkScheduledJobRan(ctx context.Context, params db.MarkScheduledJobRanParams) (db.CoreScheduledJob, error)
PruneScheduledJobRuns(ctx context.Context, params db.PruneScheduledJobRunsParams) error
ListProjectSecretValues(ctx context.Context, projectID uuid.UUID) ([]db.ListProjectSecretValuesRow, error)
}
type JobScheduler struct {
@@ -552,6 +553,10 @@ func (j *JobScheduler) markRan(ctx context.Context, job db.CoreScheduledJob, sta
// deliver POSTs the job payload to its URL, signed like a webhook delivery.
func (j *JobScheduler) deliver(ctx context.Context, job db.CoreScheduledJob, run db.CoreScheduledJobRun) (*int32, error) {
data, err := resolveSecretRefs(ctx, j.repo.ListProjectSecretValues, j.enc, job.ProjectID, job.Payload)
if err != nil {
return nil, err
}
payload, _ := json.Marshal(map[string]any{
"event": WebhookEventJobRun,
"project_id": job.ProjectID.String(),
@@ -560,7 +565,7 @@ func (j *JobScheduler) deliver(ctx context.Context, job db.CoreScheduledJob, run
"run_id": run.ID.String(),
"triggered_by": run.TriggeredBy,
"occurred_at": time.Now().UTC().Format(time.RFC3339),
"data": json.RawMessage(job.Payload),
"data": data,
})
secret := ""
if len(job.Secret) > 0 && j.enc != nil {
@@ -0,0 +1,234 @@
package services
import (
"context"
"encoding/json"
"fmt"
"regexp"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
"github.com/tdvorak/primora/apps/backend/internal/models"
"github.com/tdvorak/primora/apps/backend/internal/secrets"
)
// Project secrets are the server-side counterpart of the CLI's local vault:
// AES-256-GCM at rest, metadata-only listings, audited reveal. Job payloads
// reference them as secret://NAME — resolved at delivery, never stored in
// plaintext config.
var projectSecretNameRe = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
type ProjectSecretSummary struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
Notes string `json:"notes"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type SetProjectSecretInput struct {
Name string `json:"name"` // from the path param, not the body
Value string `json:"value" validate:"required"`
URL *string `json:"url"`
Notes *string `json:"notes"`
}
type RevealProjectSecretResponse struct {
Name string `json:"name"`
Value string `json:"value"`
}
func toProjectSecretSummary(row db.ListProjectSecretsRow) ProjectSecretSummary {
return ProjectSecretSummary{
ID: row.ID,
Name: row.Name,
URL: row.Url,
Notes: row.Notes,
CreatedAt: row.CreatedAt.Time,
UpdatedAt: row.UpdatedAt.Time,
}
}
func (s *PlatformService) ListProjectSecrets(ctx context.Context, actor *models.Actor, projectID uuid.UUID) ([]ProjectSecretSummary, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
return nil, err
}
rows, err := s.repo.Queries().ListProjectSecrets(ctx, projectID)
if err != nil {
return nil, err
}
out := make([]ProjectSecretSummary, 0, len(rows))
for _, row := range rows {
out = append(out, toProjectSecretSummary(row))
}
return out, nil
}
func (s *PlatformService) SetProjectSecret(ctx context.Context, actor *models.Actor, projectID uuid.UUID, input SetProjectSecretInput, requestID string) (ProjectSecretSummary, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
return ProjectSecretSummary{}, err
}
name := strings.TrimSpace(input.Name)
if !projectSecretNameRe.MatchString(name) {
return ProjectSecretSummary{}, fmt.Errorf("invalid secret name — use env-var-safe names [A-Za-z_][A-Za-z0-9_]*")
}
if s.enc == nil {
return ProjectSecretSummary{}, fmt.Errorf("encryption is not configured on this deployment")
}
ciphertext, err := s.enc.Encrypt([]byte(input.Value))
if err != nil {
return ProjectSecretSummary{}, err
}
// Merge url/notes over the existing row when the caller omits them —
// `secrets set NAME` should not wipe metadata it never saw.
var url, notes string
if existing, err := s.repo.Queries().GetProjectSecret(ctx, db.GetProjectSecretParams{ProjectID: projectID, Name: name}); err == nil {
url, notes = existing.Url, existing.Notes
} else if err != pgx.ErrNoRows {
return ProjectSecretSummary{}, err
}
if input.URL != nil {
url = strings.TrimSpace(*input.URL)
}
if input.Notes != nil {
notes = strings.TrimSpace(*input.Notes)
}
row, err := s.repo.Queries().UpsertProjectSecret(ctx, db.UpsertProjectSecretParams{
ProjectID: projectID,
Name: name,
Ciphertext: ciphertext,
Url: url,
Notes: notes,
})
if err != nil {
return ProjectSecretSummary{}, err
}
if project, err := s.repo.Queries().GetProjectByID(ctx, projectID); err == nil {
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "secret.set", "project_secret", row.ID.String(), map[string]any{
"name": row.Name,
}))
}
return ProjectSecretSummary{
ID: row.ID,
Name: row.Name,
URL: row.Url,
Notes: row.Notes,
CreatedAt: row.CreatedAt.Time,
UpdatedAt: row.UpdatedAt.Time,
}, nil
}
func (s *PlatformService) DeleteProjectSecret(ctx context.Context, actor *models.Actor, projectID uuid.UUID, name string, requestID string) error {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
return err
}
row, err := s.repo.Queries().DeleteProjectSecret(ctx, db.DeleteProjectSecretParams{ProjectID: projectID, Name: name})
if err != nil {
return err
}
if project, err := s.repo.Queries().GetProjectByID(ctx, projectID); err == nil {
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "secret.deleted", "project_secret", row.String(), map[string]any{
"name": name,
}))
}
return nil
}
// Reveal decrypts a single secret. Deliberately loud: audit-logged every time.
func (s *PlatformService) RevealProjectSecret(ctx context.Context, actor *models.Actor, projectID uuid.UUID, name string, requestID string) (RevealProjectSecretResponse, error) {
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
return RevealProjectSecretResponse{}, err
}
row, err := s.repo.Queries().GetProjectSecret(ctx, db.GetProjectSecretParams{ProjectID: projectID, Name: name})
if err != nil {
return RevealProjectSecretResponse{}, err
}
plain, err := s.enc.Decrypt(row.Ciphertext)
if err != nil {
return RevealProjectSecretResponse{}, err
}
if project, err := s.repo.Queries().GetProjectByID(ctx, projectID); err == nil {
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "secret.revealed", "project_secret", row.ID.String(), map[string]any{
"name": row.Name,
}))
}
return RevealProjectSecretResponse{Name: row.Name, Value: string(plain)}, nil
}
// resolveSecretRefs replaces "secret://NAME" string values in a payload with
// decrypted values. Names that do not resolve fail loudly — a literal
// secret:// string reaching the endpoint is worse than a failed run.
// Package-level so the JobScheduler can reuse it through its narrow repo iface.
func resolveSecretRefs(
ctx context.Context,
list func(context.Context, uuid.UUID) ([]db.ListProjectSecretValuesRow, error),
enc *secrets.Encryptor,
projectID uuid.UUID,
payload json.RawMessage,
) (json.RawMessage, error) {
if len(payload) == 0 || !strings.Contains(string(payload), "secret://") {
return payload, nil
}
if enc == nil {
return nil, fmt.Errorf("payload references project secrets but encryption is not configured on this deployment")
}
rows, err := list(ctx, projectID)
if err != nil {
return nil, err
}
values := make(map[string]string, len(rows))
for _, row := range rows {
plain, err := enc.Decrypt(row.Ciphertext)
if err != nil {
return nil, fmt.Errorf("project secret %q failed to decrypt", row.Name)
}
values[row.Name] = string(plain)
}
var doc any
if err := json.Unmarshal(payload, &doc); err != nil {
return nil, fmt.Errorf("payload is not valid JSON: %w", err)
}
var missing []string
resolved := walkSecretRefs(doc, values, &missing)
if len(missing) > 0 {
return nil, fmt.Errorf("unresolved secret references: %s", strings.Join(missing, ", "))
}
out, err := json.Marshal(resolved)
if err != nil {
return nil, err
}
return out, nil
}
func walkSecretRefs(node any, values map[string]string, missing *[]string) any {
switch v := node.(type) {
case string:
if strings.HasPrefix(v, "secret://") {
name := strings.TrimPrefix(v, "secret://")
if resolved, ok := values[name]; ok {
return resolved
}
*missing = append(*missing, name)
return v
}
return v
case map[string]any:
for k, item := range v {
v[k] = walkSecretRefs(item, values, missing)
}
return v
case []any:
for i, item := range v {
v[i] = walkSecretRefs(item, values, missing)
}
return v
default:
return node
}
}
@@ -0,0 +1,125 @@
package services
import (
"context"
"encoding/base64"
"encoding/json"
"strings"
"testing"
"github.com/google/uuid"
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
"github.com/tdvorak/primora/apps/backend/internal/secrets"
)
func testEncryptor(t *testing.T) *secrets.Encryptor {
t.Helper()
key := base64.StdEncoding.EncodeToString(make([]byte, 32))
enc, err := secrets.NewEncryptor(key)
if err != nil {
t.Fatalf("NewEncryptor: %v", err)
}
return enc
}
func secretLister(t *testing.T, enc *secrets.Encryptor, kv map[string]string) func(context.Context, uuid.UUID) ([]db.ListProjectSecretValuesRow, error) {
t.Helper()
return func(context.Context, uuid.UUID) ([]db.ListProjectSecretValuesRow, error) {
rows := make([]db.ListProjectSecretValuesRow, 0, len(kv))
for name, value := range kv {
ct, err := enc.Encrypt([]byte(value))
if err != nil {
t.Fatalf("encrypt %s: %v", name, err)
}
rows = append(rows, db.ListProjectSecretValuesRow{Name: name, Ciphertext: ct})
}
return rows, nil
}
}
func TestResolveSecretRefs(t *testing.T) {
enc := testEncryptor(t)
list := secretLister(t, enc, map[string]string{
"API_KEY": "sk_live_99",
"RECURSIVE": "secret://API_KEY", // must not resolve twice
})
pid := uuid.New()
t.Run("replaces nested and array refs", func(t *testing.T) {
payload := json.RawMessage(`{
"token": "secret://API_KEY",
"nested": {"deep": ["a", "secret://API_KEY", {"x": "secret://API_KEY"}]},
"plain": "not a secret",
"num": 42
}`)
out, err := resolveSecretRefs(context.Background(), list, enc, pid, payload)
if err != nil {
t.Fatalf("resolve: %v", err)
}
var doc map[string]any
if err := json.Unmarshal(out, &doc); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if doc["token"] != "sk_live_99" {
t.Errorf("token = %v", doc["token"])
}
deep := doc["nested"].(map[string]any)["deep"].([]any)
if deep[1] != "sk_live_99" || deep[2].(map[string]any)["x"] != "sk_live_99" {
t.Errorf("nested refs not resolved: %v", deep)
}
if doc["plain"] != "not a secret" || doc["num"] != float64(42) {
t.Errorf("non-ref values changed: %v", doc)
}
})
t.Run("missing ref fails the job", func(t *testing.T) {
_, err := resolveSecretRefs(context.Background(), list, enc, pid, json.RawMessage(`{"a":"secret://NOPE"}`))
if err == nil || !strings.Contains(err.Error(), "NOPE") {
t.Fatalf("expected unresolved error naming NOPE, got %v", err)
}
})
t.Run("embedded ref in a larger string is not resolved", func(t *testing.T) {
out, err := resolveSecretRefs(context.Background(), list, enc, pid, json.RawMessage(`{"a":"prefix secret://API_KEY suffix"}`))
if err != nil {
t.Fatalf("resolve: %v", err)
}
if !strings.Contains(string(out), "secret://API_KEY") {
t.Fatalf("embedded ref must pass through unchanged: %s", out)
}
})
t.Run("resolved value is not re-scanned", func(t *testing.T) {
out, err := resolveSecretRefs(context.Background(), list, enc, pid, json.RawMessage(`{"a":"secret://RECURSIVE"}`))
if err != nil {
t.Fatalf("resolve: %v", err)
}
var doc map[string]any
_ = json.Unmarshal(out, &doc)
if doc["a"] != "secret://API_KEY" {
t.Fatalf("single-pass resolution expected, got %v", doc["a"])
}
})
t.Run("invalid JSON fails loudly when a ref is present", func(t *testing.T) {
_, err := resolveSecretRefs(context.Background(), list, enc, pid, json.RawMessage(`{secret://API_KEY`))
if err == nil {
t.Fatal("expected JSON error")
}
})
t.Run("payloads without refs pass through untouched", func(t *testing.T) {
payload := json.RawMessage(`{"a":1}`)
out, err := resolveSecretRefs(context.Background(), list, enc, pid, payload)
if err != nil || string(out) != string(payload) {
t.Fatalf("passthrough failed: %v %s", err, out)
}
// Nil encryptor must fail loudly — a literal secret:// reaching the
// endpoint is worse than a failed run.
_, err = resolveSecretRefs(context.Background(), list, nil, pid, json.RawMessage(`{"a":"secret://API_KEY"}`))
if err == nil {
t.Fatal("expected error when encryption is not configured")
}
})
}
+139
View File
@@ -1838,6 +1838,91 @@ paths:
description: text/event-stream of {type, occurred_at, data} messages
"401":
$ref: "#/components/responses/ErrorResponse"
/projects/{projectID}/secrets:
get:
tags: [Secrets]
operationId: listProjectSecrets
summary: Lists project secrets — metadata only, values are never included.
security:
- bearerAuth: []
- apiKeyAuth: []
parameters:
- $ref: "#/components/parameters/ProjectID"
responses:
"200":
description: Project secrets (metadata only)
content:
application/json:
schema:
$ref: "#/components/schemas/ProjectSecretListResponse"
"401":
$ref: "#/components/responses/ErrorResponse"
/projects/{projectID}/secrets/{name}:
put:
tags: [Secrets]
operationId: setProjectSecret
summary: Creates or replaces a project secret (AES-256-GCM at rest). Omitted url/notes keep existing values. Job payloads can reference it as secret://NAME, resolved at delivery.
security:
- bearerAuth: []
- apiKeyAuth: []
parameters:
- $ref: "#/components/parameters/ProjectID"
- $ref: "#/components/parameters/SecretName"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/SetProjectSecretRequest"
responses:
"200":
description: Secret stored
content:
application/json:
schema:
$ref: "#/components/schemas/ProjectSecret"
"400":
$ref: "#/components/responses/ErrorResponse"
"401":
$ref: "#/components/responses/ErrorResponse"
delete:
tags: [Secrets]
operationId: deleteProjectSecret
security:
- bearerAuth: []
- apiKeyAuth: []
parameters:
- $ref: "#/components/parameters/ProjectID"
- $ref: "#/components/parameters/SecretName"
responses:
"204":
description: Secret deleted
"401":
$ref: "#/components/responses/ErrorResponse"
"404":
$ref: "#/components/responses/ErrorResponse"
/projects/{projectID}/secrets/{name}/reveal:
post:
tags: [Secrets]
operationId: revealProjectSecret
summary: Returns the decrypted value. Every call is audit-logged.
security:
- bearerAuth: []
- apiKeyAuth: []
parameters:
- $ref: "#/components/parameters/ProjectID"
- $ref: "#/components/parameters/SecretName"
responses:
"200":
description: Decrypted value
content:
application/json:
schema:
$ref: "#/components/schemas/RevealProjectSecretResponse"
"401":
$ref: "#/components/responses/ErrorResponse"
"404":
$ref: "#/components/responses/ErrorResponse"
components:
securitySchemes:
bearerAuth:
@@ -1863,6 +1948,13 @@ components:
schema:
type: string
format: uuid
SecretName:
name: name
in: path
required: true
schema:
type: string
pattern: "^[A-Za-z_][A-Za-z0-9_]*$"
BucketID:
name: bucketID
in: path
@@ -3664,3 +3756,50 @@ components:
items:
$ref: "#/components/schemas/ScheduledJobRun"
required: [items]
ProjectSecret:
type: object
properties:
id:
type: string
format: uuid
name:
type: string
description: Env-var-safe name; referenced as secret://NAME in job payloads.
url:
type: string
notes:
type: string
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
required: [id, name, url, notes, created_at, updated_at]
ProjectSecretListResponse:
type: object
properties:
items:
type: array
items:
$ref: "#/components/schemas/ProjectSecret"
required: [items]
SetProjectSecretRequest:
type: object
properties:
value:
type: string
description: Plaintext — stored AES-256-GCM encrypted, never returned by list.
url:
type: string
notes:
type: string
required: [value]
RevealProjectSecretResponse:
type: object
properties:
name:
type: string
value:
type: string
required: [name, value]