mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 07:41:23 +00:00
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:
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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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]
|
||||
Reference in new issue
Block a user