mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-10-28 15:51: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,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
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user