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

+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")
}
})
}