From 24ba53f85a6a9400cebb7080963b7b9160610f12 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 02:45:12 +0000 Subject: [PATCH 1/6] docs(adr): ADR-027 SSM-based runtime environment loading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Formalize the intended SSM/Secrets env-loading feature: app-config flag `ssm: { enable: true }` auto-attaches the AWS Parameters & Secrets Lambda Extension, loads params under /${service}/${stage} into process.env on cold start (SecureString KMS-decrypted, cached on warm invocations). Reconciles the three coexisting mechanisms today — the merged IAM-only SsmBuilder, the unmerged draft loader on feature/finish-ssm-based-env-management, and build-time appDefinition.environment — and flags the doc/code drift in ssm-configuration.md. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019BQcoqYva5TXM2DQAyEG5o --- .../027-ssm-runtime-environment-loading.md | 123 ++++++++++++++++++ docs/architecture-decisions/README.md | 1 + 2 files changed, 124 insertions(+) create mode 100644 docs/architecture-decisions/027-ssm-runtime-environment-loading.md diff --git a/docs/architecture-decisions/027-ssm-runtime-environment-loading.md b/docs/architecture-decisions/027-ssm-runtime-environment-loading.md new file mode 100644 index 000000000..bf1c9ec10 --- /dev/null +++ b/docs/architecture-decisions/027-ssm-runtime-environment-loading.md @@ -0,0 +1,123 @@ +# ADR-027: SSM-Based Runtime Environment Loading + +**Status**: Proposed +**Date**: 2026-07-10 +**Deciders**: Sean Matthews + +## Context + +Frigg apps run as Lambdas that need runtime configuration and secrets (`DATABASE_URL`, API keys, +etc.). Today **three overlapping mechanisms** exist — only one is the intended end state, and the +merged docs describe behavior that was never merged: + +1. **Build-time env injection** — `appDefinition.environment` (merged, commit `6fa8219`). Wires env + vars into the serverless template at **deploy time** (e.g. from CI secrets). Values are baked per + deploy. +2. **IAM-only SSM** — `SsmBuilder` (merged on `next`, `packages/devtools/infrastructure/domains/parameters/ssm-builder.js`). + `ssm: { enable: true }` currently produces **only** IAM `ssm:Get*` permissions scoped to + `/${service}/${stage}/*`. No layer, no runtime loading — **the flag grants access to parameters + nothing reads.** +3. **Runtime SSM→env loader (the intended feature)** — draft only, on branch + `feature/finish-ssm-based-env-management` @ `3dcf1f8` ("Draft concept", Sean Matthews, Sep 2025), + **never merged**. Auto-attaches the AWS Parameters & Secrets Lambda Extension layer and, on + invocation, loads SSM params under the prefix into `process.env` (SecureString KMS-decrypted). The + draft carries **two competing loaders** — a core `create-handler.js` hook (`packages/core/core/ssm-parameters.js`) + and a devtools handler wrapper (`packages/devtools/handlers/ssm-handler-wrapper.js`) — never + consolidated. +4. **Doc/code drift** — `docs/reference/ssm-configuration.md` (merged) already *describes* the + layer + env-population behavior of #3, which is not in the merged code. + +The intent: an app-config flag that makes runtime configuration "just work" — put params in SSM, +flip `ssm.enable`, and the app reads them from `process.env` with no per-integration code, secrets +KMS-decrypted, without baking values into the deploy. + +## Decision + +Adopt the **runtime SSM/Secrets loader via the AWS Parameters & Secrets Lambda Extension** as the +canonical mechanism, gated by `ssm: { enable: true }`, and reconcile it with the merged pieces. + +1. **One flag, full behavior.** `ssm: { enable: true }` (optional `architecture: 'x86_64' | 'arm64'`) + does three things: attach the extension layer, grant the scoped IAM, and load params at runtime. + The merged `SsmBuilder` becomes **one component** (the IAM half) of the full feature, not the + whole thing. + ```js + const appDefinition = { + name: 'my-frigg-app', + integrations: [ /* ... */ ], + ssm: { enable: true, architecture: 'x86_64' }, // architecture optional + }; + ``` + +2. **Load via the extension; cache on warm invocations.** Read parameters through the extension's + localhost endpoint (not the SDK) under prefix `/${service}/${stage}`, mapping keys to `process.env` + as UPPER_SNAKE (`database-url` → `DATABASE_URL`). Load on cold start / handler init and rely on + the extension's local cache (configurable TTL) for warm invocations — **not** a fresh SSM + round-trip per request. + ```js + // create-handler hook (consolidated single loader) + await parametersToEnv(process.env.SSM_PARAMETER_PREFIX); // withDecryption, recursive + // maps /svc/stage/database-url -> process.env.DATABASE_URL + ``` + +3. **SSM + Secrets Manager, one layer.** The same extension serves both. Use **SSM Parameter Store** + for configuration/non-secret params and **Secrets Manager** for secrets; both KMS-decrypted + (SecureString / SM). Consolidate the draft's two competing loaders into a single core loader path. + +4. **Distinct from build-time env.** Runtime SSM loading (invocation-time; values live in SSM) and + `appDefinition.environment` (deploy-time; values baked into the template) are **complementary, not + competing**: build-time for values known at deploy, runtime SSM for values that rotate or differ + per environment without a redeploy. Keep both; document when to use which. + +5. **KMS / SecureString policy.** All reads use `withDecryption: true`; SecureString params are + KMS-decrypted by SSM. Under `vpc.enable`, provision the **SSM (and Secrets Manager) VPC endpoint** + so the extension reaches the service from private subnets. Builds on the already-merged KMS + + VPC-endpoint work (field-level encryption / `KmsBuilder`). + +6. **Operational specifics.** Pin/parameterize the extension layer version (the draft hardcodes + `:19`); select the layer ARN by region × architecture; keep the `/${service}/${stage}/...` naming + contract; and **fix the doc/code drift** in `docs/reference/ssm-configuration.md` to match shipped + behavior. + +### Open decisions (for Sean) +- **Refresh model:** cold-start-only vs extension-cache TTL vs explicit refresh. *(Recommend + extension cache with a configurable TTL.)* +- **SSM vs Secrets Manager split:** enforce a convention (params vs secrets) or allow either for both? +- **Layer default-on:** the draft attaches the layer unless `secrets.enable === false`. Keep it + opt-in via `ssm.enable` / `secrets.enable`, or default-on when either is configured? + +## Consequences + +### Positive +- Config/secrets "just work" from SSM with no per-integration code; nothing secret baked into deploys. +- KMS decryption handled transparently; one extension serves both SSM and Secrets Manager. +- Turns the currently-inert `ssm.enable` flag into a complete feature. + +### Negative +- Adds a Lambda layer (cold-start weight) + extension configuration. +- VPC mode requires the SSM/Secrets VPC endpoints. +- Real work remains: consolidate the draft's two loaders, reconcile with the merged `SsmBuilder`, + pin the layer version, and add CI tests. + +### Neutral +- Establishes `/${service}/${stage}` naming + UPPER_SNAKE mapping as the contract. +- Supersedes the `feature/finish-ssm-based-env-management` draft branch. + +## Alternatives Considered +- **Keep IAM-only `SsmBuilder`.** Rejected: grants access to parameters nothing loads — the flag is + inert. +- **SDK-based loading (no extension).** Rejected: the extension provides caching + Secrets Manager + and avoids SDK weight in the handler path. +- **Build-time env only (`appDefinition.environment`).** Rejected as the sole mechanism: bakes values + into deploys; no rotation without redeploy. +- **Ship the draft as-is.** Rejected: two competing loaders, hardcoded layer version, no VPC + endpoint, tests not wired to CI. + +## Related +- Draft: `feature/finish-ssm-based-env-management` @ `3dcf1f8` — `packages/core/core/ssm-parameters.js`, + `packages/core/core/create-handler.js`, `packages/devtools/infrastructure/aws-ssm-layer-arns.js`, + `packages/devtools/infrastructure/serverless-template.js`, + `packages/devtools/handlers/ssm-handler-wrapper.js`, `packages/devtools/utils/ssm-parameter-store.js`. +- Merged: `packages/devtools/infrastructure/domains/parameters/ssm-builder.js`, + `docs/reference/ssm-configuration.md` (needs a drift fix). +- Build-time env: `appDefinition.environment` (commit `6fa8219`). +- KMS field-level encryption + VPC endpoints (merged) — the KMS foundation this relies on. diff --git a/docs/architecture-decisions/README.md b/docs/architecture-decisions/README.md index 73308132b..ca068ffce 100644 --- a/docs/architecture-decisions/README.md +++ b/docs/architecture-decisions/README.md @@ -28,6 +28,7 @@ An ADR documents a significant architectural decision made in the project, inclu | [009](./009-e2e-test-package.md) | E2E Test Package | Accepted | 2025-12-15 | | [010](./010-reporting-as-admin-operation.md) | Reporting as an Admin Operation | Proposed | 2026-06-30 | | [011](./011-integration-telemetry-and-usage-tracking.md) | Integration Telemetry, Eventing & Feature-Usage Tracking | Proposed | 2026-06-30 | +| [027](./027-ssm-runtime-environment-loading.md) | SSM-Based Runtime Environment Loading | Proposed | 2026-07-10 | ## ADR Template From 8b46fd7456916ee6cd74a3d86b878e41d728e009 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 04:24:49 +0000 Subject: [PATCH 2/6] docs(adr): reframe 027 into Configuration & Secrets model; add 028 (provider plugin) + 029 (management) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the narrow SSM-runtime-loading ADR with a broader Configuration & Secrets model: three scopes (platform / app-level per-API-module / instance) x {config, secret}, the env-overload problem (10+ modules x OAuth creds), the free-and-fast -> mature adoption path, the instance-secret isolation invariant, and a new app-level module-credential tier (Integration.config/Credential are instance- scoped and NOT that home). 028 makes secrets/config a provider plugin (AWS/GCP/Azure/1Password/Vault/DB) per ADR-PLUGINS; the SSM/Secrets runtime loader becomes the AWS adapter. 029 makes an admin API the single management brain that routes storage by app definition, with the CLI, curl, and a future GUI as thin clients. Drafts — open questions recorded; not yet for review. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019BQcoqYva5TXM2DQAyEG5o --- .../027-configuration-and-secrets-model.md | 106 +++++++++++++++ .../027-ssm-runtime-environment-loading.md | 123 ------------------ .../028-secrets-config-provider-plugin.md | 89 +++++++++++++ .../029-variable-secret-management.md | 68 ++++++++++ docs/architecture-decisions/README.md | 4 +- 5 files changed, 266 insertions(+), 124 deletions(-) create mode 100644 docs/architecture-decisions/027-configuration-and-secrets-model.md delete mode 100644 docs/architecture-decisions/027-ssm-runtime-environment-loading.md create mode 100644 docs/architecture-decisions/028-secrets-config-provider-plugin.md create mode 100644 docs/architecture-decisions/029-variable-secret-management.md diff --git a/docs/architecture-decisions/027-configuration-and-secrets-model.md b/docs/architecture-decisions/027-configuration-and-secrets-model.md new file mode 100644 index 000000000..f2978b18f --- /dev/null +++ b/docs/architecture-decisions/027-configuration-and-secrets-model.md @@ -0,0 +1,106 @@ +# ADR-027: Configuration & Secrets — Model & Tiers + +**Status**: Draft +**Date**: 2026-07-10 +**Deciders**: Sean Matthews + +> Draft capturing the design in progress. Companion ADRs: **ADR-028** (provider plugin) and +> **ADR-029** (management interface). Open questions at the end; not yet ready to send. + +## Context + +Today Frigg can really only set environment variables **manually at build time**: the declarative +`appDefinition.environment` list pulls values from the deploy pipeline and injects them into the +functions via serverless-oss. Beyond that, an adopter must hand-roll a Lambda layer or already know +about the `secrets-to-env.js` (Secrets Manager) path; the SSM runtime loader is drafted and unmerged. + +That single flat env list **blends separable concerns with no distinction**: +- **Infra/runtime** config — `DATABASE_URL`, `KMS_KEY_ARN`, `STAGE` (framework-owned). +- **Framework-behavior** settings — how Frigg itself should behave. +- **Per-environment / dev flags** — feature flags, code-path toggles a dev wants per stage. +- **API-module app credentials** — the client id / secret / scopes each API module needs. + +The last category is the one that breaks. **Every adopter eventually hits "env overload,"** usually +at **10+ API modules**, each with OAuth-style credentials (client id + secret + scopes ≈ 3+ values) += **30+ "env variables"** in one undifferentiated list. + +**Correction to an earlier assumption:** the existing DB models are **instance-scoped** and are *not* +a home for app/definition-level env or module credentials: +- `Integration.config` — per-**instance** user/instance configuration. +- `Credential` — auth for a given `Entity` (rolls up to an `Integration` via relationship), + injected into the API-class instance for the `Requester` base-class fetch. Per-instance + authentication only. + +So there is currently **no first-class home** for app-level, per-API-module credentials/config — +they get dumped into the flat platform env, which is exactly what overloads. + +## Decision — a tiered model + +Configuration/secrets sort into **three scopes**, each crossed with **{config, secret}**: + +| Scope | Config | Secret | Where today | +|---|---|---|---| +| **1. Platform / app-wide** (per env) | infra, framework behavior, feature/dev flags | platform secrets | build-time `environment` → env; Secrets Manager → env (live); SSM (draft) | +| **2. App-level per-API-module** (per env, shared across tenants) | module settings/scopes | **module client id/secret** | ❌ **no home — dumped into flat env (the overload)** | +| **3. Instance / per-tenant** | `Integration.config` | `Credential.data` (field-encrypted) | ✅ exists; injected on demand | + +**Invariant (keep):** tier-3 secrets are **never** promoted to `process.env` — they're decrypted and +injected into the module instance on demand. This isolation already holds and must stay. + +**The new concept is tier 2** — an app-level, per-module credential/config store, distinct from the +instance `Credential`/`config`. Two ways to realize it (open decision): +- **Option A — mirror models:** new `ModuleCredential` / `ModuleConfig` tables/collections, scoped by + **app + environment + module** (not by `Entity`), secrets field-encrypted via the existing registry. +- **Option B — repurpose existing models at a different reference/filter POV:** app/definition-level + rows in `Credential`/config, distinguished from instance rows by scope/reference. + +Either way: field-encrypt the secrets, and **inject per module at build/instantiation** rather than +flattening them into the global env — which both removes the overload and scopes each module to only +its own credentials. + +### Maturation path (free & fast → mature) — a hard requirement + +No adopter should pay for a tier they don't use; each level is opt-in and backward compatible: + +- **L0 — day one, free/fast:** build-time env via `appDefinition.environment` from the pipeline. Zero + infra. (What exists today.) +- **L1:** platform secrets via a provider store (Secrets Manager / SSM / provider), opt-in. +- **L2:** structured **app-level module credentials** (tier 2) — solves env overload — DB-backed or + provider-namespaced, managed via the admin API / CLI (ADR-029). +- **L3:** multi-provider (GCP / Azure / 1Password / Vault, ADR-028), **per-function scoping** + (least privilege), and a hosted management GUI. + +### Least-privilege scoping (gap to close) + +Today env is **global to every function** and IAM sits on one shared role — a function gets *all* +envs, not only what it needs. The target (phased, likely L3): a **per-function env manifest** + +**per-function role**, so each function receives only its declared keys/paths. Tier-2's per-module +injection is a step toward this. + +## Consequences + +### Positive +- Names and separates the four concerns currently mashed into one env list. +- Gives app-level module credentials a real home → directly kills env overload at scale. +- Preserves the (already-true) instance-secret isolation invariant. +- Free/fast default with an opt-in maturation path — no day-one tax. + +### Negative +- Introduces a new storage tier (tier 2) to design, build, and migrate onto. +- Requires reconciling three overlapping platform-env mechanisms (build-time, Secrets Manager, SSM). + +### Neutral +- Establishes scope × sensitivity as the vocabulary the CLI/API and docs are organized around. + +## Open questions (to resolve) +- **Tier-2 storage:** Option A (mirror models) vs Option B (repurpose existing at app-level filter)? +- Do app-level module secrets live in the **DB** or in a **provider store** (or either, per adopter)? +- **Least-privilege scoping:** commit to per-function manifest + per-function role, and when (L3)? +- Rule for what counts as **platform** vs **app-level-module** vs **instance** — is the boundary + always obvious, or do we need explicit categorization in the app definition? + +## Related +- [ADR-028: Secrets & Config Provider Plugin](./028-secrets-config-provider-plugin.md) +- [ADR-029: Variable & Secret Management (Admin API / CLI / GUI)](./029-variable-secret-management.md) +- ADR-PLUGINS (provider/database/encryption plugin taxonomy), ADR-005 / ADR-010 (admin surface), + field-level encryption registry (`packages/core/database/encryption/`). diff --git a/docs/architecture-decisions/027-ssm-runtime-environment-loading.md b/docs/architecture-decisions/027-ssm-runtime-environment-loading.md deleted file mode 100644 index bf1c9ec10..000000000 --- a/docs/architecture-decisions/027-ssm-runtime-environment-loading.md +++ /dev/null @@ -1,123 +0,0 @@ -# ADR-027: SSM-Based Runtime Environment Loading - -**Status**: Proposed -**Date**: 2026-07-10 -**Deciders**: Sean Matthews - -## Context - -Frigg apps run as Lambdas that need runtime configuration and secrets (`DATABASE_URL`, API keys, -etc.). Today **three overlapping mechanisms** exist — only one is the intended end state, and the -merged docs describe behavior that was never merged: - -1. **Build-time env injection** — `appDefinition.environment` (merged, commit `6fa8219`). Wires env - vars into the serverless template at **deploy time** (e.g. from CI secrets). Values are baked per - deploy. -2. **IAM-only SSM** — `SsmBuilder` (merged on `next`, `packages/devtools/infrastructure/domains/parameters/ssm-builder.js`). - `ssm: { enable: true }` currently produces **only** IAM `ssm:Get*` permissions scoped to - `/${service}/${stage}/*`. No layer, no runtime loading — **the flag grants access to parameters - nothing reads.** -3. **Runtime SSM→env loader (the intended feature)** — draft only, on branch - `feature/finish-ssm-based-env-management` @ `3dcf1f8` ("Draft concept", Sean Matthews, Sep 2025), - **never merged**. Auto-attaches the AWS Parameters & Secrets Lambda Extension layer and, on - invocation, loads SSM params under the prefix into `process.env` (SecureString KMS-decrypted). The - draft carries **two competing loaders** — a core `create-handler.js` hook (`packages/core/core/ssm-parameters.js`) - and a devtools handler wrapper (`packages/devtools/handlers/ssm-handler-wrapper.js`) — never - consolidated. -4. **Doc/code drift** — `docs/reference/ssm-configuration.md` (merged) already *describes* the - layer + env-population behavior of #3, which is not in the merged code. - -The intent: an app-config flag that makes runtime configuration "just work" — put params in SSM, -flip `ssm.enable`, and the app reads them from `process.env` with no per-integration code, secrets -KMS-decrypted, without baking values into the deploy. - -## Decision - -Adopt the **runtime SSM/Secrets loader via the AWS Parameters & Secrets Lambda Extension** as the -canonical mechanism, gated by `ssm: { enable: true }`, and reconcile it with the merged pieces. - -1. **One flag, full behavior.** `ssm: { enable: true }` (optional `architecture: 'x86_64' | 'arm64'`) - does three things: attach the extension layer, grant the scoped IAM, and load params at runtime. - The merged `SsmBuilder` becomes **one component** (the IAM half) of the full feature, not the - whole thing. - ```js - const appDefinition = { - name: 'my-frigg-app', - integrations: [ /* ... */ ], - ssm: { enable: true, architecture: 'x86_64' }, // architecture optional - }; - ``` - -2. **Load via the extension; cache on warm invocations.** Read parameters through the extension's - localhost endpoint (not the SDK) under prefix `/${service}/${stage}`, mapping keys to `process.env` - as UPPER_SNAKE (`database-url` → `DATABASE_URL`). Load on cold start / handler init and rely on - the extension's local cache (configurable TTL) for warm invocations — **not** a fresh SSM - round-trip per request. - ```js - // create-handler hook (consolidated single loader) - await parametersToEnv(process.env.SSM_PARAMETER_PREFIX); // withDecryption, recursive - // maps /svc/stage/database-url -> process.env.DATABASE_URL - ``` - -3. **SSM + Secrets Manager, one layer.** The same extension serves both. Use **SSM Parameter Store** - for configuration/non-secret params and **Secrets Manager** for secrets; both KMS-decrypted - (SecureString / SM). Consolidate the draft's two competing loaders into a single core loader path. - -4. **Distinct from build-time env.** Runtime SSM loading (invocation-time; values live in SSM) and - `appDefinition.environment` (deploy-time; values baked into the template) are **complementary, not - competing**: build-time for values known at deploy, runtime SSM for values that rotate or differ - per environment without a redeploy. Keep both; document when to use which. - -5. **KMS / SecureString policy.** All reads use `withDecryption: true`; SecureString params are - KMS-decrypted by SSM. Under `vpc.enable`, provision the **SSM (and Secrets Manager) VPC endpoint** - so the extension reaches the service from private subnets. Builds on the already-merged KMS + - VPC-endpoint work (field-level encryption / `KmsBuilder`). - -6. **Operational specifics.** Pin/parameterize the extension layer version (the draft hardcodes - `:19`); select the layer ARN by region × architecture; keep the `/${service}/${stage}/...` naming - contract; and **fix the doc/code drift** in `docs/reference/ssm-configuration.md` to match shipped - behavior. - -### Open decisions (for Sean) -- **Refresh model:** cold-start-only vs extension-cache TTL vs explicit refresh. *(Recommend - extension cache with a configurable TTL.)* -- **SSM vs Secrets Manager split:** enforce a convention (params vs secrets) or allow either for both? -- **Layer default-on:** the draft attaches the layer unless `secrets.enable === false`. Keep it - opt-in via `ssm.enable` / `secrets.enable`, or default-on when either is configured? - -## Consequences - -### Positive -- Config/secrets "just work" from SSM with no per-integration code; nothing secret baked into deploys. -- KMS decryption handled transparently; one extension serves both SSM and Secrets Manager. -- Turns the currently-inert `ssm.enable` flag into a complete feature. - -### Negative -- Adds a Lambda layer (cold-start weight) + extension configuration. -- VPC mode requires the SSM/Secrets VPC endpoints. -- Real work remains: consolidate the draft's two loaders, reconcile with the merged `SsmBuilder`, - pin the layer version, and add CI tests. - -### Neutral -- Establishes `/${service}/${stage}` naming + UPPER_SNAKE mapping as the contract. -- Supersedes the `feature/finish-ssm-based-env-management` draft branch. - -## Alternatives Considered -- **Keep IAM-only `SsmBuilder`.** Rejected: grants access to parameters nothing loads — the flag is - inert. -- **SDK-based loading (no extension).** Rejected: the extension provides caching + Secrets Manager - and avoids SDK weight in the handler path. -- **Build-time env only (`appDefinition.environment`).** Rejected as the sole mechanism: bakes values - into deploys; no rotation without redeploy. -- **Ship the draft as-is.** Rejected: two competing loaders, hardcoded layer version, no VPC - endpoint, tests not wired to CI. - -## Related -- Draft: `feature/finish-ssm-based-env-management` @ `3dcf1f8` — `packages/core/core/ssm-parameters.js`, - `packages/core/core/create-handler.js`, `packages/devtools/infrastructure/aws-ssm-layer-arns.js`, - `packages/devtools/infrastructure/serverless-template.js`, - `packages/devtools/handlers/ssm-handler-wrapper.js`, `packages/devtools/utils/ssm-parameter-store.js`. -- Merged: `packages/devtools/infrastructure/domains/parameters/ssm-builder.js`, - `docs/reference/ssm-configuration.md` (needs a drift fix). -- Build-time env: `appDefinition.environment` (commit `6fa8219`). -- KMS field-level encryption + VPC endpoints (merged) — the KMS foundation this relies on. diff --git a/docs/architecture-decisions/028-secrets-config-provider-plugin.md b/docs/architecture-decisions/028-secrets-config-provider-plugin.md new file mode 100644 index 000000000..b7a31033f --- /dev/null +++ b/docs/architecture-decisions/028-secrets-config-provider-plugin.md @@ -0,0 +1,89 @@ +# ADR-028: Secrets & Config Provider Plugin + +**Status**: Draft +**Date**: 2026-07-10 +**Deciders**: Sean Matthews + +> Draft. The "where do values physically live and how are they read" half of the Configuration & +> Secrets work (ADR-027 is the model; ADR-029 is management). + +## Context + +The configuration/secrets tiers in ADR-027 need concrete backends, and the reality is multi-cloud: +a live **GCP** deployment, **Azure** on the radar, plus **1Password / Vault** as secret managers +adopters already use. Today only **AWS** is implemented (Secrets Manager → env is live; SSM runtime +loading is a draft; `SsmBuilder` grants IAM but nothing reads it), and there are **zero** references +to GCP Secret Manager, Azure Key Vault, 1Password, or Vault in the code. + +There is already a hexagonal seam — `CloudProviderAdapter` (a port with an AWS impl and GCP/Azure +stubs), and **ADR-PLUGINS** proposes required-with-defaults plugins with typed core interfaces +(`provider | database | encryption | queue | scheduler`) selected via `appDefinition.plugins`. The +problem is **cloud-agnostic; only the transport is provider-specific.** + +## Decision + +Add a **`secrets` / `config` provider plugin type to the ADR-PLUGINS taxonomy** — one typed core +interface, many adapters. The framework never imports a vendor SDK directly; adopters select a +provider in the app definition. + +```js +// appDefinition (illustrative) +plugins: { + secrets: { provider: 'aws' }, // 'aws' | 'gcp' | 'azure' | 'onepassword' | 'vault' | 'database' +} +``` + +**Port (illustrative):** +```js +class SecretsConfigProvider { // Port — ADR-PLUGINS plugin interface + async resolve(keysOrPrefix, { scope, env }) {} // read → { KEY: value } + async write(key, value, { scope, env, secret }) {} // used by ADR-029 management + refreshPolicy() {} // e.g. { mode: 'per-invocation', ttlSeconds: 300 } +} +``` + +- **AWS adapter** = the existing SSM Parameter Store + Secrets Manager work, consolidated. The draft + runtime loader collapses into **one** core loader behind the port (`parametersToEnv` / + `secretsToEnv` via the Parameters & Secrets Lambda extension); `SsmBuilder` becomes its IAM half. + This subsumes the former "SSM runtime loading" ADR. +- **GCP** (Secret Manager), **Azure** (Key Vault) adapters — turn the current stubs into real impls. +- **`database` adapter** — backs ADR-027's DB tiers (app-level module creds, instance + `Credential`/`config`); the same port, a DB transport. +- **`onepassword` / `vault`** — see below. +- **Transport is adapter-internal** (Lambda extension vs SDK vs platform reference vs DB query); the + port stays cloud-neutral. Refresh/caching is a **port policy** (per-invocation with TTL — matching + the shipped `secrets-to-env` behavior — so freshness/rotation works without redeploy). + +### 1Password / Vault +Two modes, both behind the same interface: +- **Source-of-truth sync (default):** secrets live in 1Password/Vault; at **deploy** the CLI/API + resolves `op://` / `vault kv` references and syncs them into the runtime provider store — runtime + stays cloud-native and fast. Answers "is it just their toolchain?" → largely **yes** for authoring. +- **Runtime adapter (optional):** resolve at runtime via 1Password Connect / Service Accounts or + Vault agent. More moving parts; only if the store should *be* 1Password/Vault. + +## Consequences + +### Positive +- One interface unlocks AWS/GCP/Azure/1Password/Vault + DB without core changes (ADR-PLUGINS promise). +- Collapses the three competing AWS env mechanisms into a single adapter. +- Runtime stays cloud-native even when the source of truth is 1Password/Vault. + +### Negative +- Real adapter work (GCP/Azure stubs → impls; 1Password/Vault new). +- The Lambda-extension transport has no clean cross-cloud analog — the port must not leak it. + +### Neutral +- Establishes provider selection in `appDefinition.plugins` alongside database/encryption/etc. + +## Open questions +- Precedence when the same key resolves from multiple sources (e.g. Secrets Manager vs SSM vs env)? +- Layer/extension **default-on vs opt-in**. +- Do per-integration (tier-3) secrets stay **DB-only**, or may they target a provider store? +- 1Password: source-of-truth-sync only, runtime adapter only, or both? + +## Related +- [ADR-027: Configuration & Secrets — Model & Tiers](./027-configuration-and-secrets-model.md) +- [ADR-029: Variable & Secret Management](./029-variable-secret-management.md) +- ADR-PLUGINS (plugin taxonomy this extends); `CloudProviderAdapter` port + AWS adapter; + `secrets-to-env.js`; the SSM draft on `feature/finish-ssm-based-env-management`. diff --git a/docs/architecture-decisions/029-variable-secret-management.md b/docs/architecture-decisions/029-variable-secret-management.md new file mode 100644 index 000000000..23fea4a0e --- /dev/null +++ b/docs/architecture-decisions/029-variable-secret-management.md @@ -0,0 +1,68 @@ +# ADR-029: Variable & Secret Management — Admin API, CLI, GUI + +**Status**: Draft +**Date**: 2026-07-10 +**Deciders**: Sean Matthews + +> Draft. The "how do humans/tools manage values across all tiers" half of the Configuration & +> Secrets work (ADR-027 is the model; ADR-028 is the storage/provider port). + +## Context + +Even with the tier model (ADR-027) and provider port (ADR-028), adopters need a way to *manage* +variables and secrets. Today there is **no `frigg secrets` / `frigg env` command**, no unified +surface, and management is scattered: hand-edited pipeline env, `setup-gh-env-secrets.sh` scripts, a +local-only `.env` editor in the management UI. We must not solve this by putting routing logic in the +CLI (it would diverge from any future GUI). + +## Decision + +**The management interface is a set of admin API routes; every front-end is a thin client of them.** + +- **Admin API is the one brain.** Routes (admin-authed, same surface family as ADR-005 / ADR-010) + accept a value plus its **category / scope / sensitivity**, and the **API decides where to store + it** based on the Frigg base app config/definition (which providers/tiers are configured per + ADR-027/028) and the target environment. Storage routing lives here, once. +- **The CLI is a client of those routes** — and, because they're plain admin API, the same calls are + **curl-able** and a **hosted GUI/UI** can drive all layers of variables and secrets through the + identical API. One API, many front-ends, no duplicated logic. +- **Smart routing** = a function of (sensitivity: config vs secret) × (scope: platform / + app-level-module / instance) × (environment) × (configured provider). The caller states intent; + the API places it: platform → provider store; app-level module cred → tier-2 store (DB/provider); + instance → `Integration.config` / `Credential`. + +**Illustrative CLI (thin wrappers over admin routes):** +``` +frigg config set FEATURE_X=on --stage prod # platform config → SSM / App Config +frigg secret set DATABASE_URL --stage prod # platform secret → Secrets Manager / KV / 1Password +frigg module cred set hubspot --client-id … --secret … --scopes … --stage prod # app-level module → tier-2 store +frigg integration config set key=val # instance config → Integration.config (DB) +``` +``` +GET/PUT /api/v2/admin/variables # curl or GUI hit the same routes the CLI does +``` + +## Consequences + +### Positive +- One place decides storage routing → CLI, curl, and a future GUI stay consistent by construction. +- Adopters manage all tiers through a uniform surface; a hosted UI becomes "just another client." +- Reuses the established admin-auth/admin-operation surface rather than a new mechanism. + +### Negative +- Requires admin write routes that mutate real secret stores — needs careful auth, redaction, audit. +- Routing rules must be well-specified so `set` is predictable. + +### Neutral +- Positions variable/secret management as part of Frigg's admin API, alongside reporting/scripts. + +## Open questions +- **Routing:** inferred (from sensitivity/scope) vs explicit subcommands/flags? +- **Auth:** reuse `ADMIN_API_KEY` / reporting key, or a dedicated management credential? +- **Read-back & redaction:** can values be read back (never secrets in plaintext?), and what's audited? +- Where the **routing policy** itself is declared — app definition vs API config. + +## Related +- [ADR-027: Configuration & Secrets — Model & Tiers](./027-configuration-and-secrets-model.md) +- [ADR-028: Secrets & Config Provider Plugin](./028-secrets-config-provider-plugin.md) +- ADR-005 (Admin Script Runner — admin auth/surface), ADR-010 (admin operations / `/api/v2/admin`). diff --git a/docs/architecture-decisions/README.md b/docs/architecture-decisions/README.md index ca068ffce..8fcd7b262 100644 --- a/docs/architecture-decisions/README.md +++ b/docs/architecture-decisions/README.md @@ -28,7 +28,9 @@ An ADR documents a significant architectural decision made in the project, inclu | [009](./009-e2e-test-package.md) | E2E Test Package | Accepted | 2025-12-15 | | [010](./010-reporting-as-admin-operation.md) | Reporting as an Admin Operation | Proposed | 2026-06-30 | | [011](./011-integration-telemetry-and-usage-tracking.md) | Integration Telemetry, Eventing & Feature-Usage Tracking | Proposed | 2026-06-30 | -| [027](./027-ssm-runtime-environment-loading.md) | SSM-Based Runtime Environment Loading | Proposed | 2026-07-10 | +| [027](./027-configuration-and-secrets-model.md) | Configuration & Secrets — Model & Tiers | Draft | 2026-07-10 | +| [028](./028-secrets-config-provider-plugin.md) | Secrets & Config Provider Plugin | Draft | 2026-07-10 | +| [029](./029-variable-secret-management.md) | Variable & Secret Management (Admin API / CLI / GUI) | Draft | 2026-07-10 | ## ADR Template From 576d0d2cb6f47b1f19f41b157536db3b7d0bb65f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 04:58:31 +0000 Subject: [PATCH 3/6] docs(adr): fold config/secrets design decisions into 027-029 027: rename tier-3 to "per-connection" (vs deployment tenancy); add management-plane vs runtime-plane; expand variable scoping (global vs module-scoped vs per-connection, derived from the integration->module graph, usage-based); record resolved decisions (both runtime modes w/ materialized default; per-connection DB-default + deferred non-Frigg credential-source override) and the function-granularity open question. 028: port is read+write; runtime mode decided (both; materialized default for external managers, w/ bootstrap-token rationale); mode selects scoping realization; 1Password both modes; deferred credentialSource adapter. 029: routing map (tier x env -> backend); "unified 1Password" = all tiers -> 1password; first-class local provider default; scoping emitted from the graph, not hand-maintained. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019BQcoqYva5TXM2DQAyEG5o --- .../027-configuration-and-secrets-model.md | 69 +++++++++++++++---- .../028-secrets-config-provider-plugin.md | 33 +++++++-- .../029-variable-secret-management.md | 23 ++++++- 3 files changed, 101 insertions(+), 24 deletions(-) diff --git a/docs/architecture-decisions/027-configuration-and-secrets-model.md b/docs/architecture-decisions/027-configuration-and-secrets-model.md index f2978b18f..d38feec93 100644 --- a/docs/architecture-decisions/027-configuration-and-secrets-model.md +++ b/docs/architecture-decisions/027-configuration-and-secrets-model.md @@ -41,11 +41,25 @@ Configuration/secrets sort into **three scopes**, each crossed with **{config, s | Scope | Config | Secret | Where today | |---|---|---|---| | **1. Platform / app-wide** (per env) | infra, framework behavior, feature/dev flags | platform secrets | build-time `environment` → env; Secrets Manager → env (live); SSM (draft) | -| **2. App-level per-API-module** (per env, shared across tenants) | module settings/scopes | **module client id/secret** | ❌ **no home — dumped into flat env (the overload)** | -| **3. Instance / per-tenant** | `Integration.config` | `Credential.data` (field-encrypted) | ✅ exists; injected on demand | - -**Invariant (keep):** tier-3 secrets are **never** promoted to `process.env` — they're decrypted and -injected into the module instance on demand. This isolation already holds and must stay. +| **2. App-level per-API-module** (per env, shared across all connections) | module settings/scopes | **module client id/secret** | ❌ **no home — dumped into flat env (the overload)** | +| **3. Per-connection** (instance) | `Integration.config` | `Credential.data` (field-encrypted) | ✅ exists; injected on demand | + +> **Terminology:** tier 3 is **per-connection** — one `Credential` per `Entity`, i.e. the tokens a +> specific end user gets when they authorize *their* account (`User → Integration → Entity → +> Credential`). Not to be confused with **deployment tenancy** (one Frigg instance per adopter +> customer), a separate axis. Tier 2 is the **app-level** OAuth *application* credential (one per +> module per environment, shared by all connections) — e.g. the HubSpot `client_id`/`secret` you +> register once. Tier 3 is what Jane gets when she clicks "Connect HubSpot." + +**Invariant (keep):** tier-3 (per-connection) secrets are **never** promoted to `process.env` — +they're decrypted and injected into the module instance on demand. This isolation already holds and +must stay. + +**Two planes.** Every tier has a **management plane** (where values are authored — the admin API / +CLI / UI, ADR-029) and a **runtime plane** (where the app reads them). Any provider (ADR-028) can +serve one or both: e.g. an adopter can make **1Password the system of record** for tiers 1–2 while +the runtime reads from a cloud store (materialized) or from 1Password directly. Tier 3 is authored by +the *running app* (at connect time), not by an admin — so it lives in the DB by default. **The new concept is tier 2** — an app-level, per-module credential/config store, distinct from the instance `Credential`/`config`. Two ways to realize it (open decision): @@ -70,12 +84,30 @@ No adopter should pay for a tier they don't use; each level is opt-in and backwa - **L3:** multi-provider (GCP / Azure / 1Password / Vault, ADR-028), **per-function scoping** (least privilege), and a hosted management GUI. -### Least-privilege scoping (gap to close) +### Variable scoping — deliver only what a function needs + +**Principle: a variable reaches only the bundled functions that need it.** Three scoping levels, +which Frigg can **derive from the app definition's integration→module graph** (it already knows which +integration uses which modules — no hand-authored manifest): + +- **Global** — `DATABASE_URL`, `KMS_KEY_ARN`, `STAGE`, framework-behavior settings → every function. +- **Module-scoped** — tier-2 creds like `HUBSPOT_CLIENT_ID`/`SECRET`/scopes → **only functions that + run the HubSpot module.** The Asana function never sees them *unless* it also runs HubSpot — the + rule is **usage-based, not name-based**, so the graph handles the "adopter runs HubSpot through + their Asana app" case correctly. +- **Per-connection** — tier-3 → never in env; fetched on demand for the specific connection. -Today env is **global to every function** and IAM sits on one shared role — a function gets *all* -envs, not only what it needs. The target (phased, likely L3): a **per-function env manifest** + -**per-function role**, so each function receives only its declared keys/paths. Tier-2's per-module -injection is a step toward this. +This maps onto the two runtime modes (ADR-028): +- **`materialized`** → each function's env = `global ∪ creds(modules it serves)` (a per-function env + manifest). +- **`direct`** → each function's role/identity is scoped to `global ∪ module-cred paths for modules + it serves`; modules pull creds at instantiation — which also **eliminates env-overload** (nothing + sits in env). + +**Today this is a gap:** env is global to every function on one shared IAM role. Realizing true +least-privilege depends on **function-bundling granularity** — split functions by module usage, or +lean on `direct` on-demand fetch so a multiplexing function only reads what it instantiates. (Open +question below.) ## Consequences @@ -92,12 +124,21 @@ injection is a step toward this. ### Neutral - Establishes scope × sensitivity as the vocabulary the CLI/API and docs are organized around. +### Resolved so far +- **Runtime modes:** support **both** `materialized` and `direct`; **`materialized` is the default** + for external managers on serverless (avoids a bootstrap token in functions). (ADR-028.) +- **Per-connection creds:** **DB by default.** A pluggable **non-Frigg credential source** (resolve + tier-3 from an adopter's own store) is a considered, overridable **extension point — deferred, not + built now.** +- **Terminology:** the instance tier is **per-connection** (not "tenant"). + ## Open questions (to resolve) - **Tier-2 storage:** Option A (mirror models) vs Option B (repurpose existing at app-level filter)? -- Do app-level module secrets live in the **DB** or in a **provider store** (or either, per adopter)? -- **Least-privilege scoping:** commit to per-function manifest + per-function role, and when (L3)? -- Rule for what counts as **platform** vs **app-level-module** vs **instance** — is the boundary - always obvious, or do we need explicit categorization in the app definition? +- **Function-bundling granularity:** split functions per integration/module to get env-injection + scoping, or rely on `direct` on-demand fetch for multiplexing functions? (Gates true + least-privilege.) +- Rule for what counts as **platform** vs **app-level-module** vs **per-connection** — always obvious + from the graph, or does the app definition need explicit categorization for edge cases? ## Related - [ADR-028: Secrets & Config Provider Plugin](./028-secrets-config-provider-plugin.md) diff --git a/docs/architecture-decisions/028-secrets-config-provider-plugin.md b/docs/architecture-decisions/028-secrets-config-provider-plugin.md index b7a31033f..624036d15 100644 --- a/docs/architecture-decisions/028-secrets-config-provider-plugin.md +++ b/docs/architecture-decisions/028-secrets-config-provider-plugin.md @@ -33,15 +33,29 @@ plugins: { } ``` -**Port (illustrative):** +**Port (illustrative) — read *and* write, so any provider can be system-of-record and/or runtime source:** ```js class SecretsConfigProvider { // Port — ADR-PLUGINS plugin interface - async resolve(keysOrPrefix, { scope, env }) {} // read → { KEY: value } - async write(key, value, { scope, env, secret }) {} // used by ADR-029 management - refreshPolicy() {} // e.g. { mode: 'per-invocation', ttlSeconds: 300 } + async resolve(keysOrPrefix, { scope, env }) {} // runtime read → { KEY: value } + async write(key, value, { scope, env, secret }) {} // management-plane write (ADR-029) + runtimeMode() {} // 'materialized' | 'direct' + refreshPolicy() {} // e.g. { ttlSeconds: 300 } } ``` +**Runtime mode (decided): support both, `materialized` is the default for external managers.** +- **`materialized`** — values are synced into the function's runtime store (cloud env/SSM/Secrets or + the DB tier) at write/deploy; the function never calls the external manager at runtime. Default for + 1Password/Vault on serverless, because `direct` needs a **bootstrap secret** (a Service-Account + token) living in platform env — chicken-and-egg — whereas cloud-native stores authorize via the + function's IAM role with no stored token. +- **`direct`** — the function reads from the provider at cold start (Connect / Service Account / SDK). + Available for adopters who want the store to *literally be* 1Password/Vault at runtime. + +Mode also selects how **variable scoping** (ADR-027) is realized: `materialized` → per-function env +manifest (`global ∪ creds(modules the function serves)`); `direct` → per-function IAM scoped to those +paths, creds pulled at module instantiation. + - **AWS adapter** = the existing SSM Parameter Store + Secrets Manager work, consolidated. The draft runtime loader collapses into **one** core loader behind the port (`parametersToEnv` / `secretsToEnv` via the Parameters & Secrets Lambda extension); `SsmBuilder` becomes its IAM half. @@ -76,11 +90,16 @@ Two modes, both behind the same interface: ### Neutral - Establishes provider selection in `appDefinition.plugins` alongside database/encryption/etc. +### Resolved +- **Runtime mode:** support both; **`materialized` default** for external managers (see above). +- **1Password/Vault:** both modes (source-of-truth-sync **and** runtime adapter); sync is the default. +- **Per-connection (tier-3):** **DB by default.** A **pluggable non-Frigg credential source** — a + `credentialSource` adapter so an adopter can resolve tier-3 from their own store — is a considered, + overridable extension point, **deferred, not built now.** + ## Open questions - Precedence when the same key resolves from multiple sources (e.g. Secrets Manager vs SSM vs env)? -- Layer/extension **default-on vs opt-in**. -- Do per-integration (tier-3) secrets stay **DB-only**, or may they target a provider store? -- 1Password: source-of-truth-sync only, runtime adapter only, or both? +- Layer/extension **default-on vs opt-in** (ties to ADR-027 function-granularity). ## Related - [ADR-027: Configuration & Secrets — Model & Tiers](./027-configuration-and-secrets-model.md) diff --git a/docs/architecture-decisions/029-variable-secret-management.md b/docs/architecture-decisions/029-variable-secret-management.md index 23fea4a0e..f5f8ad397 100644 --- a/docs/architecture-decisions/029-variable-secret-management.md +++ b/docs/architecture-decisions/029-variable-secret-management.md @@ -27,9 +27,26 @@ CLI (it would diverge from any future GUI). **curl-able** and a **hosted GUI/UI** can drive all layers of variables and secrets through the identical API. One API, many front-ends, no duplicated logic. - **Smart routing** = a function of (sensitivity: config vs secret) × (scope: platform / - app-level-module / instance) × (environment) × (configured provider). The caller states intent; - the API places it: platform → provider store; app-level module cred → tier-2 store (DB/provider); - instance → `Integration.config` / `Credential`. + app-level-module / per-connection) × (environment) × (configured provider). The caller states + intent; the API places it: platform → provider store; app-level module cred → tier-2 store + (DB/provider); per-connection → `Integration.config` / `Credential`. +- **Routing map (per tier × environment → backend).** The placement rules live in a declared map so + the same intent routes differently per env, and **"unified 1Password" = set every tier's backend to + `1password`** (persona: "manage all of it in one place"). Mixed setups are just a different map. + ```js + // app definition (illustrative) + secretsRouting: { + prod: { platform: '1password', appModule: '1password', perConnection: 'database' }, + local: { platform: 'local', appModule: 'local', perConnection: 'database' }, + } + ``` +- **Local parity.** A first-class **`local` provider** (gitignored file / docker DB) is the default + backend for local envs, so `frigg start` + the CLI/UI work with **zero cloud**. Because backend is + per-environment, a dev who lives in 1Password can point `local` at it too (via `op`), ideally + `materialized` so there's no runtime dependency. +- **Scoping is emitted, not manual.** On deploy/build the API + infra derive each function's variable + set / IAM from the integration→module graph (ADR-027) — the management layer never hand-maintains + per-function manifests. **Illustrative CLI (thin wrappers over admin routes):** ``` From e1af1129e1751edd0b4cc6b3546c90b6a6522dfd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 05:03:43 +0000 Subject: [PATCH 4/6] docs(adr): resolve tier-2 storage (mirror models) + confirm function scoping in code Tier-2 = Option A: new ModuleCredential/ModuleConfig models (app+env+module scoped, field-encrypted) rather than overloading Credential/config. Confirmed against infra code that per-function env/IAM scoping is feasible today without restructuring: integration-builder emits per-integration functions (HTTP/webhook/queue/extension), base-definition-factory packages individually; env is global only because infrastructure-composer Object.assigns onto provider.environment. materialized scoping = emit functions[name].environment from the integration->module graph. Closes the function-granularity open question. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019BQcoqYva5TXM2DQAyEG5o --- .../027-configuration-and-secrets-model.md | 35 +++++++++++++------ 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/docs/architecture-decisions/027-configuration-and-secrets-model.md b/docs/architecture-decisions/027-configuration-and-secrets-model.md index d38feec93..f6ade149f 100644 --- a/docs/architecture-decisions/027-configuration-and-secrets-model.md +++ b/docs/architecture-decisions/027-configuration-and-secrets-model.md @@ -104,10 +104,20 @@ This maps onto the two runtime modes (ADR-028): it serves`; modules pull creds at instantiation — which also **eliminates env-overload** (nothing sits in env). -**Today this is a gap:** env is global to every function on one shared IAM role. Realizing true -least-privilege depends on **function-bundling granularity** — split functions by module usage, or -lean on `direct` on-demand fetch so a multiplexing function only reads what it instantiates. (Open -question below.) +**Confirmed against the code — the granularity is already there.** The infra builder emits +**per-integration functions**: `integration-builder.js` creates, per integration, an HTTP handler +(`functions[integrationName]`), a webhook handler (`{name}Webhook`), a queue worker, and one function +per extension binding (`{name}__{binding}`) — and packaging is **`package: { individually: true }`** +(`base-definition-factory.js`), so each is a discrete, separately-packaged Lambda. Per-function +env/IAM scoping therefore needs **no bundling restructure**. + +Env is global today only by **wiring**: the composer does +`Object.assign(provider.environment, merged.environment)` (`infrastructure-composer.js`), dumping all +vars onto the shared provider block. The fix (`materialized` mode) is to emit +`functions[name].environment` = `global ∪ creds(that integration's modules)` from the +integration→module graph instead of onto the global block; shared/non-integration functions (auth, +health, reporting, db-migrate) get `global` only. `direct` mode remains the alternative that keeps +creds out of env entirely. ## Consequences @@ -125,20 +135,25 @@ question below.) - Establishes scope × sensitivity as the vocabulary the CLI/API and docs are organized around. ### Resolved so far +- **Tier-2 storage: Option A — mirror models.** New `ModuleCredential` / `ModuleConfig` + tables/collections scoped by **app + environment + module** (not by `Entity`), secrets + field-encrypted via the existing registry. Chosen for clarity/cleanliness over overloading + `Credential`/`config`. - **Runtime modes:** support **both** `materialized` and `direct`; **`materialized` is the default** for external managers on serverless (avoids a bootstrap token in functions). (ADR-028.) - **Per-connection creds:** **DB by default.** A pluggable **non-Frigg credential source** (resolve tier-3 from an adopter's own store) is a considered, overridable **extension point — deferred, not built now.** +- **Function scoping is feasible now** (confirmed in code): functions are already per-integration and + `individually` packaged, so `materialized` per-function env/IAM is a wiring change (emit per-function + env from the graph), not a bundling restructure. - **Terminology:** the instance tier is **per-connection** (not "tenant"). ## Open questions (to resolve) -- **Tier-2 storage:** Option A (mirror models) vs Option B (repurpose existing at app-level filter)? -- **Function-bundling granularity:** split functions per integration/module to get env-injection - scoping, or rely on `direct` on-demand fetch for multiplexing functions? (Gates true - least-privilege.) -- Rule for what counts as **platform** vs **app-level-module** vs **per-connection** — always obvious - from the graph, or does the app definition need explicit categorization for edge cases? +- Rule for what counts as **platform** vs **app-level-module** vs **per-connection** — always + derivable from the integration→module graph, or does the app definition need explicit + categorization for edge cases? +- Key-collision **precedence** when a value resolves from more than one source (shared with ADR-028). ## Related - [ADR-028: Secrets & Config Provider Plugin](./028-secrets-config-provider-plugin.md) From ca1dd74d0fbf6ba89cfa6d63faf4f4618010dc48 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 05:12:03 +0000 Subject: [PATCH 5/6] docs(adr): resolve boundary + precedence; add diagrams and samples to 027-029 Resolve the last two open questions: scope boundary = derive-by-default with explicit per-variable override; precedence = separate namespaces + one backend per (tier,env) via the routing map, provider/routing-map wins on real overlaps, local overrides locally, validation warns (strict optional). Add ASCII diagrams to all three (027 tier/plane flow; 028 provider port + adapters + materialized/direct; 029 one-API-many-clients + routing) and a scope-annotation sample to 027. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019BQcoqYva5TXM2DQAyEG5o --- .../027-configuration-and-secrets-model.md | 40 ++++++++++++++++--- .../028-secrets-config-provider-plugin.md | 24 ++++++++++- .../029-variable-secret-management.md | 14 +++++++ 3 files changed, 70 insertions(+), 8 deletions(-) diff --git a/docs/architecture-decisions/027-configuration-and-secrets-model.md b/docs/architecture-decisions/027-configuration-and-secrets-model.md index f6ade149f..a096e539e 100644 --- a/docs/architecture-decisions/027-configuration-and-secrets-model.md +++ b/docs/architecture-decisions/027-configuration-and-secrets-model.md @@ -61,6 +61,16 @@ serve one or both: e.g. an adopter can make **1Password the system of record** f the runtime reads from a cloud store (materialized) or from 1Password directly. Tier 3 is authored by the *running app* (at connect time), not by an admin — so it lives in the DB by default. +``` + SCOPE MANAGEMENT PLANE STORE (routing map, ADR-029/028) RUNTIME PLANE + admin API / CLI / UI + Tier 1 platform ──write──► SSM / Secrets / 1Password / local ──► process.env (every function) + Tier 2 app-module ──write──► ModuleCredential/Config | provider ──► env of functions running + that module (scoped) + Tier 3 per-conn. (written by running app) DB Credential / Integration.config ──► injected on demand, + never in process.env +``` + **The new concept is tier 2** — an app-level, per-module credential/config store, distinct from the instance `Credential`/`config`. Two ways to realize it (open decision): - **Option A — mirror models:** new `ModuleCredential` / `ModuleConfig` tables/collections, scoped by @@ -104,6 +114,19 @@ This maps onto the two runtime modes (ADR-028): it serves`; modules pull creds at instantiation — which also **eliminates env-overload** (nothing sits in env). +**Boundary rule — derive by default, annotate the edge cases.** Tier-2 module creds are derived from +the module's auth definition + the graph (no annotation); per-connection is inherently distinct; +everything else defaults to platform-global. The app definition can override scope for +adopter-specific values the graph can't infer: + +```js +environment: { + DATABASE_URL: true, // platform (global) — inferred + MY_HUBSPOT_ONLY_FLAG: { scope: 'hubspot' }, // override: only functions running the hubspot module +} +// HUBSPOT_CLIENT_ID/SECRET/scopes (tier-2) need no annotation — derived from the module + graph. +``` + **Confirmed against the code — the granularity is already there.** The infra builder emits **per-integration functions**: `integration-builder.js` creates, per integration, an HTTP handler (`functions[integrationName]`), a webhook handler (`{name}Webhook`), a queue worker, and one function @@ -148,12 +171,17 @@ creds out of env entirely. `individually` packaged, so `materialized` per-function env/IAM is a wiring change (emit per-function env from the graph), not a bundling restructure. - **Terminology:** the instance tier is **per-connection** (not "tenant"). - -## Open questions (to resolve) -- Rule for what counts as **platform** vs **app-level-module** vs **per-connection** — always - derivable from the integration→module graph, or does the app definition need explicit - categorization for edge cases? -- Key-collision **precedence** when a value resolves from more than one source (shared with ADR-028). +- **Scope boundary:** **derive by default, annotate the edge cases** — tier-2 from module + graph, + per-connection inherently distinct, everything else platform-global; app definition can override a + variable's scope. +- **Precedence:** tiers are separate namespaces (most-specific wins: per-connection > app-module > + platform); the routing map fixes **one backend per (tier, env)** so there's no in-tier collision by + construction; genuine overlaps → provider/routing-map source wins, local overrides for local env, + deploy validation **warns** (opt-in strict mode fails). + +## Open questions (implementation detail, non-blocking) +- Exact **annotation syntax** for scope overrides in the app definition. +- Whether collision detection defaults to **warn** or **strict**. ## Related - [ADR-028: Secrets & Config Provider Plugin](./028-secrets-config-provider-plugin.md) diff --git a/docs/architecture-decisions/028-secrets-config-provider-plugin.md b/docs/architecture-decisions/028-secrets-config-provider-plugin.md index 624036d15..a028a168e 100644 --- a/docs/architecture-decisions/028-secrets-config-provider-plugin.md +++ b/docs/architecture-decisions/028-secrets-config-provider-plugin.md @@ -56,6 +56,21 @@ Mode also selects how **variable scoping** (ADR-027) is realized: `materialized` manifest (`global ∪ creds(modules the function serves)`); `direct` → per-function IAM scoped to those paths, creds pulled at module instantiation. +``` + write (ADR-029) runtime read + │ ▲ + ▼ │ + ┌──────────────────── SecretsConfigProvider (port) ──────────────────┐ + │ resolve() write() runtimeMode() refreshPolicy() │ + └───┬──────────┬──────────┬───────────┬──────────────┬───────────────┘ + aws gcp azure 1password database + (SSM+SM) (Secret Mgr)(Key Vault)(Connect/SA) (ModuleCredential / Credential) + + materialized (default, external mgrs): write ─► sync into runtime store ─► process.env + direct: function ─► provider at cold start + (needs bootstrap token; cloud-native uses IAM) +``` + - **AWS adapter** = the existing SSM Parameter Store + Secrets Manager work, consolidated. The draft runtime loader collapses into **one** core loader behind the port (`parametersToEnv` / `secretsToEnv` via the Parameters & Secrets Lambda extension); `SsmBuilder` becomes its IAM half. @@ -97,9 +112,14 @@ Two modes, both behind the same interface: `credentialSource` adapter so an adopter can resolve tier-3 from their own store — is a considered, overridable extension point, **deferred, not built now.** +- **Precedence (resolved):** the routing map (ADR-029) fixes **one backend per (tier, env)** → no + in-tier collision by construction. For genuine overlaps (e.g. legacy build-time env vs the + routing-map backend), the **provider/routing-map source wins**; the **local** provider overrides + for the local env only; deploy validation **warns** (opt-in strict mode fails). + ## Open questions -- Precedence when the same key resolves from multiple sources (e.g. Secrets Manager vs SSM vs env)? -- Layer/extension **default-on vs opt-in** (ties to ADR-027 function-granularity). +- Layer/extension **default-on vs opt-in** (minor; with per-function scoping confirmed, the extension + attaches only to functions that need `direct` reads). ## Related - [ADR-027: Configuration & Secrets — Model & Tiers](./027-configuration-and-secrets-model.md) diff --git a/docs/architecture-decisions/029-variable-secret-management.md b/docs/architecture-decisions/029-variable-secret-management.md index f5f8ad397..f3848ddde 100644 --- a/docs/architecture-decisions/029-variable-secret-management.md +++ b/docs/architecture-decisions/029-variable-secret-management.md @@ -48,6 +48,20 @@ CLI (it would diverge from any future GUI). set / IAM from the integration→module graph (ADR-027) — the management layer never hand-maintains per-function manifests. +``` + CLI curl GUI / UI (thin clients — no routing logic) + └─────────┼──────────┘ + ▼ + ┌──────────────── Admin API ─────────────────┐ + │ reads routing map (tier × env → backend); │ + │ places / resolves each value once │ + └───┬──────────────┬─────────────────┬────────┘ + platform app-module per-connection + provider store ModuleCredential/ DB Credential / + (SSM/Secrets/ provider Integration.config + 1Password/local) +``` + **Illustrative CLI (thin wrappers over admin routes):** ``` frigg config set FEATURE_X=on --stage prod # platform config → SSM / App Config From 7e88101b3da8359f222bc7178d3ef1ec17d03b04 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 05:43:15 +0000 Subject: [PATCH 6/6] docs(adr): apply adversarial-review consistency fixes to config/secrets ADRs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Status Draft -> Proposed on ADR-027/028/029/030 + README index (Draft is not in the sanctioned status vocabulary) - ADR-027: collapse stale tier-2 'open decision' Option A/B block (mirror models is decided; alternative moved to Alternatives Considered) - ADR-027/030: reconcile tier-3 with ADR-024 Global Entities — tier-3 now covers both per-end-user connections and admin-authored global entities (userId:null); isolation invariant restated to include both - ADR-028: frame the new secrets/config type as an ADR-016 taxonomy extension; soften 'zero references' to 'no functional impl, only stubs'; fix TTL attribution to the AWS Parameters&Secrets Lambda extension - ADR-029: mark /api/v2/admin/variables as this ADR's own namespace (not ADR-010's); refresh stale reporting-key open question - ADR-PLUGINS mnemonic -> ADR-016; add Alternatives Considered + cross links (024/030) per ADR-014 convention; align 029 title with the index Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_019BQcoqYva5TXM2DQAyEG5o --- .../027-configuration-and-secrets-model.md | 71 ++++++++++++------- .../028-secrets-config-provider-plugin.md | 47 +++++++----- .../029-variable-secret-management.md | 24 +++++-- ...nfiguration-secrets-docs-and-maturation.md | 21 ++++-- docs/architecture-decisions/README.md | 8 +-- 5 files changed, 114 insertions(+), 57 deletions(-) diff --git a/docs/architecture-decisions/027-configuration-and-secrets-model.md b/docs/architecture-decisions/027-configuration-and-secrets-model.md index 19265e228..b6cf93842 100644 --- a/docs/architecture-decisions/027-configuration-and-secrets-model.md +++ b/docs/architecture-decisions/027-configuration-and-secrets-model.md @@ -1,11 +1,12 @@ # ADR-027: Configuration & Secrets — Model & Tiers -**Status**: Draft +**Status**: Proposed **Date**: 2026-07-10 **Deciders**: Sean Matthews -> Draft capturing the design in progress. Companion ADRs: **ADR-028** (provider plugin) and -> **ADR-029** (management interface). Open questions at the end; not yet ready to send. +> Proposed. Companion ADRs: **ADR-028** (provider plugin), **ADR-029** (management interface), and +> **ADR-030** (docs & adopter maturation). Open questions at the end are implementation detail and +> non-blocking. ## Context @@ -40,26 +41,31 @@ Configuration/secrets sort into **three scopes**, each crossed with **{config, s | Scope | Config | Secret | Where today | |---|---|---|---| -| **1. Platform / app-wide** (per env) | infra, framework behavior, feature/dev flags | platform secrets | build-time `environment` → env; Secrets Manager → env (live); SSM (draft) | +| **1. Platform / app-wide** (per env) | infra, framework behavior, feature/dev flags | platform secrets | build-time `environment` → env; Secrets Manager → env (needs manual `SECRET_ARN` + Parameters&Secrets extension-layer wiring); SSM (draft) | | **2. App-level per-API-module** (per env, shared across all connections) | module settings/scopes | **module client id/secret** | ❌ **no home — dumped into flat env (the overload)** | -| **3. Per-connection** (instance) | `Integration.config` | `Credential.data` (field-encrypted) | ✅ exists; injected on demand | - -> **Terminology:** tier 3 is **per-connection** — one `Credential` per `Entity`, i.e. the tokens a -> specific end user gets when they authorize *their* account (`User → Integration → Entity → -> Credential`). Not to be confused with **deployment tenancy** (one Frigg instance per adopter -> customer), a separate axis. Tier 2 is the **app-level** OAuth *application* credential (one per -> module per environment, shared by all connections) — e.g. the HubSpot `client_id`/`secret` you -> register once. Tier 3 is what Jane gets when she clicks "Connect HubSpot." - -**Invariant (keep):** tier-3 (per-connection) secrets are **never** promoted to `process.env` — -they're decrypted and injected into the module instance on demand. This isolation already holds and -must stay. +| **3. Per-connection** | `Integration.config` | `Credential.data` (field-encrypted) | ✅ exists; injected on demand | + +> **Terminology:** tier 3 is **per-connection** — a `Credential` per `Entity`, i.e. the tokens a +> connection holds against an external service. The common case is a **specific end user** who +> authorizes *their* account (`User → Integration → Entity → Credential`) — what Jane gets when she +> clicks "Connect HubSpot." A second case is an **app-owner–owned global connection** (ADR-024 Global +> Entities): an admin authorizes once at deploy, the `Credential`/`Entity` carries `userId: null`, and +> every end user shares that single connection to the external service. Both are tier 3 — connection +> credentials in the DB, field-encrypted — and differ only in ownership (per-end-user vs global). +> Not to be confused with **deployment tenancy** (one Frigg deployment per adopter customer), a +> separate axis. Tier 2 is the **app-level** OAuth *application* credential (one per module per +> environment, shared by all connections) — e.g. the HubSpot `client_id`/`secret` you register once. + +**Invariant (keep):** tier-3 (per-connection) secrets — end-user *and* global — are **never** promoted +to `process.env`; they're decrypted and injected into the module instance on demand. This isolation +already holds and must stay. **Two planes.** Every tier has a **management plane** (where values are authored — the admin API / CLI / UI, ADR-029) and a **runtime plane** (where the app reads them). Any provider (ADR-028) can serve one or both: e.g. an adopter can make **1Password the system of record** for tiers 1–2 while -the runtime reads from a cloud store (materialized) or from 1Password directly. Tier 3 is authored by -the *running app* (at connect time), not by an admin — so it lives in the DB by default. +the runtime reads from a cloud store (materialized) or from 1Password directly. Tier 3 is authored at +**connect time** — by the end user (per-connection) or by an admin once (global entities, ADR-024) — +not through the platform-env pipeline, so it lives in the DB by default. ``` SCOPE MANAGEMENT PLANE STORE (routing map, ADR-029/028) RUNTIME PLANE @@ -72,11 +78,10 @@ the *running app* (at connect time), not by an admin — so it lives in the DB b ``` **The new concept is tier 2** — an app-level, per-module credential/config store, distinct from the -instance `Credential`/`config`. Two ways to realize it (open decision): -- **Option A — mirror models:** new `ModuleCredential` / `ModuleConfig` tables/collections, scoped by - **app + environment + module** (not by `Entity`), secrets field-encrypted via the existing registry. -- **Option B — repurpose existing models at a different reference/filter POV:** app/definition-level - rows in `Credential`/config, distinguished from instance rows by scope/reference. +instance `Credential`/`config`. It is realized by **mirroring the existing models**: new +`ModuleCredential` / `ModuleConfig` tables/collections, scoped by **app + environment + module** (not +by `Entity`), secrets field-encrypted via the existing registry. (The rejected alternative — repurpose +`Credential`/config with a scope/reference discriminator — is in *Alternatives Considered*.) Either way: field-encrypt the secrets, and **inject per module at build/instantiation** rather than flattening them into the global env — which both removes the overload and scopes each module to only @@ -160,7 +165,7 @@ creds out of env entirely. - Establishes scope × sensitivity as the vocabulary the CLI/API and docs are organized around. ### Resolved so far -- **Tier-2 storage: Option A — mirror models.** New `ModuleCredential` / `ModuleConfig` +- **Tier-2 storage: mirror models.** New `ModuleCredential` / `ModuleConfig` tables/collections scoped by **app + environment + module** (not by `Entity`), secrets field-encrypted via the existing registry. Chosen for clarity/cleanliness over overloading `Credential`/`config`. @@ -185,8 +190,22 @@ creds out of env entirely. - Exact **annotation syntax** for scope overrides in the app definition. - Whether collision detection defaults to **warn** or **strict**. +## Alternatives Considered +- **Keep the single flat env list.** Rejected: it is exactly what produces env overload at 10+ + modules and blends four separable concerns with no distinction. +- **Tier-2 by repurposing existing models.** App/definition-level rows in `Credential`/`config` + distinguished from instance rows by a scope/reference discriminator. Rejected in favor of mirrored + `ModuleCredential`/`ModuleConfig` models — a dedicated table reads more clearly and avoids + overloading instance semantics. +- **Only `direct` runtime mode.** Rejected: `direct` needs a bootstrap token in platform env for + external managers; `materialized` avoids it, so both are supported with `materialized` as default + (ADR-028). + ## Related - [ADR-028: Secrets & Config Provider Plugin](./028-secrets-config-provider-plugin.md) - [ADR-029: Variable & Secret Management (Admin API / CLI / GUI)](./029-variable-secret-management.md) -- ADR-PLUGINS (provider/database/encryption plugin taxonomy), ADR-005 / ADR-010 (admin surface), - field-level encryption registry (`packages/core/database/encryption/`). +- [ADR-030: Configuration & Secrets — Docs & Adopter Maturation](./030-configuration-secrets-docs-and-maturation.md) +- [ADR-016: Plugins](./016-plugins.md) (provider/database/encryption/queue/scheduler plugin taxonomy), + [ADR-024: Global Entities](./024-global-entities.md) (admin-authored global tier-3 connections), + ADR-005 / ADR-010 (admin surface), field-level encryption registry + (`packages/core/database/encryption/`). diff --git a/docs/architecture-decisions/028-secrets-config-provider-plugin.md b/docs/architecture-decisions/028-secrets-config-provider-plugin.md index a028a168e..25916cf2c 100644 --- a/docs/architecture-decisions/028-secrets-config-provider-plugin.md +++ b/docs/architecture-decisions/028-secrets-config-provider-plugin.md @@ -1,30 +1,32 @@ # ADR-028: Secrets & Config Provider Plugin -**Status**: Draft +**Status**: Proposed **Date**: 2026-07-10 **Deciders**: Sean Matthews -> Draft. The "where do values physically live and how are they read" half of the Configuration & -> Secrets work (ADR-027 is the model; ADR-029 is management). +> Proposed. The "where do values physically live and how are they read" half of the Configuration & +> Secrets work (ADR-027 is the model; ADR-029 is management; ADR-030 is docs & maturation). ## Context The configuration/secrets tiers in ADR-027 need concrete backends, and the reality is multi-cloud: a live **GCP** deployment, **Azure** on the radar, plus **1Password / Vault** as secret managers adopters already use. Today only **AWS** is implemented (Secrets Manager → env is live; SSM runtime -loading is a draft; `SsmBuilder` grants IAM but nothing reads it), and there are **zero** references -to GCP Secret Manager, Azure Key Vault, 1Password, or Vault in the code. +loading is a draft; `SsmBuilder` grants IAM but nothing reads it), and there is **no functional +implementation** for GCP Secret Manager, Azure Key Vault, 1Password, or Vault — only commented-out +provider stubs. There is already a hexagonal seam — `CloudProviderAdapter` (a port with an AWS impl and GCP/Azure -stubs), and **ADR-PLUGINS** proposes required-with-defaults plugins with typed core interfaces +stubs), and **ADR-016 (Plugins)** proposes required-with-defaults plugins with typed core interfaces (`provider | database | encryption | queue | scheduler`) selected via `appDefinition.plugins`. The problem is **cloud-agnostic; only the transport is provider-specific.** ## Decision -Add a **`secrets` / `config` provider plugin type to the ADR-PLUGINS taxonomy** — one typed core -interface, many adapters. The framework never imports a vendor SDK directly; adopters select a -provider in the app definition. +**Extend the ADR-016 plugin taxonomy with a new `secrets` / `config` plugin type** — this is a +taxonomy extension (a new core interface added to the five existing types), after which adapters +follow ADR-016's no-core-change rule: one typed core interface, many adapters. The framework never +imports a vendor SDK directly; adopters select an adapter in the app definition. ```js // appDefinition (illustrative) @@ -35,7 +37,7 @@ plugins: { **Port (illustrative) — read *and* write, so any provider can be system-of-record and/or runtime source:** ```js -class SecretsConfigProvider { // Port — ADR-PLUGINS plugin interface +class SecretsConfigProvider { // Port — ADR-016 plugin interface async resolve(keysOrPrefix, { scope, env }) {} // runtime read → { KEY: value } async write(key, value, { scope, env, secret }) {} // management-plane write (ADR-029) runtimeMode() {} // 'materialized' | 'direct' @@ -74,14 +76,16 @@ paths, creds pulled at module instantiation. - **AWS adapter** = the existing SSM Parameter Store + Secrets Manager work, consolidated. The draft runtime loader collapses into **one** core loader behind the port (`parametersToEnv` / `secretsToEnv` via the Parameters & Secrets Lambda extension); `SsmBuilder` becomes its IAM half. - This subsumes the former "SSM runtime loading" ADR. + This folds in the SSM runtime-loading draft (branch `feature/finish-ssm-based-env-management`) + rather than shipping it as a separate ADR. - **GCP** (Secret Manager), **Azure** (Key Vault) adapters — turn the current stubs into real impls. - **`database` adapter** — backs ADR-027's DB tiers (app-level module creds, instance `Credential`/`config`); the same port, a DB transport. - **`onepassword` / `vault`** — see below. - **Transport is adapter-internal** (Lambda extension vs SDK vs platform reference vs DB query); the - port stays cloud-neutral. Refresh/caching is a **port policy** (per-invocation with TTL — matching - the shipped `secrets-to-env` behavior — so freshness/rotation works without redeploy). + port stays cloud-neutral. Refresh/caching is a **port policy** (per-invocation with TTL — on AWS the + TTL cache is provided by the Parameters & Secrets Lambda extension that `secrets-to-env` reads + through — so freshness/rotation works without redeploy). ### 1Password / Vault Two modes, both behind the same interface: @@ -94,7 +98,8 @@ Two modes, both behind the same interface: ## Consequences ### Positive -- One interface unlocks AWS/GCP/Azure/1Password/Vault + DB without core changes (ADR-PLUGINS promise). +- One interface unlocks AWS/GCP/Azure/1Password/Vault + DB without further core changes once the type + is added (the ADR-016 promise) — adapters need no core edits. - Collapses the three competing AWS env mechanisms into a single adapter. - Runtime stays cloud-native even when the source of truth is 1Password/Vault. @@ -121,8 +126,18 @@ Two modes, both behind the same interface: - Layer/extension **default-on vs opt-in** (minor; with per-function scoping confirmed, the extension attaches only to functions that need `direct` reads). +## Alternatives Considered +- **Keep AWS-only and hand-roll other clouds per adopter.** Rejected: GCP is already live and Azure is + on the radar; a per-adopter fork multiplies maintenance and contradicts the cloud-agnostic reality. +- **A separate top-level plugin category outside the ADR-016 taxonomy.** Rejected: secrets/config fit + the existing typed-interface-plus-adapters model; a parallel mechanism would fragment plugin + selection (`appDefinition.plugins`). +- **`direct` runtime reads as the only mode.** Rejected: needs a bootstrap token in platform env for + external managers; `materialized` is the default and `direct` remains available. + ## Related - [ADR-027: Configuration & Secrets — Model & Tiers](./027-configuration-and-secrets-model.md) - [ADR-029: Variable & Secret Management](./029-variable-secret-management.md) -- ADR-PLUGINS (plugin taxonomy this extends); `CloudProviderAdapter` port + AWS adapter; - `secrets-to-env.js`; the SSM draft on `feature/finish-ssm-based-env-management`. +- [ADR-030: Configuration & Secrets — Docs & Adopter Maturation](./030-configuration-secrets-docs-and-maturation.md) +- [ADR-016: Plugins](./016-plugins.md) (plugin taxonomy this extends); `CloudProviderAdapter` port + + AWS adapter; `secrets-to-env.js`; the SSM draft on `feature/finish-ssm-based-env-management`. diff --git a/docs/architecture-decisions/029-variable-secret-management.md b/docs/architecture-decisions/029-variable-secret-management.md index f3848ddde..8d596700d 100644 --- a/docs/architecture-decisions/029-variable-secret-management.md +++ b/docs/architecture-decisions/029-variable-secret-management.md @@ -1,11 +1,12 @@ # ADR-029: Variable & Secret Management — Admin API, CLI, GUI -**Status**: Draft +**Status**: Proposed **Date**: 2026-07-10 **Deciders**: Sean Matthews -> Draft. The "how do humans/tools manage values across all tiers" half of the Configuration & -> Secrets work (ADR-027 is the model; ADR-028 is the storage/provider port). +> Proposed. The "how do humans/tools manage values across all tiers" half of the Configuration & +> Secrets work (ADR-027 is the model; ADR-028 is the storage/provider port; ADR-030 is docs & +> maturation). ## Context @@ -19,7 +20,8 @@ CLI (it would diverge from any future GUI). **The management interface is a set of admin API routes; every front-end is a thin client of them.** -- **Admin API is the one brain.** Routes (admin-authed, same surface family as ADR-005 / ADR-010) +- **Admin API is the one brain.** Routes (admin-authed, in the same admin-operation surface family as + ADR-005 / ADR-010; this ADR introduces its own `/api/v2/admin/variables` namespace) accept a value plus its **category / scope / sensitivity**, and the **API decides where to store it** based on the Frigg base app config/definition (which providers/tiers are configured per ADR-027/028) and the target environment. Storage routing lives here, once. @@ -89,11 +91,21 @@ GET/PUT /api/v2/admin/variables # curl or GUI hit the same routes the ## Open questions - **Routing:** inferred (from sensitivity/scope) vs explicit subcommands/flags? -- **Auth:** reuse `ADMIN_API_KEY` / reporting key, or a dedicated management credential? +- **Auth:** reuse the admin API key (per ADR-010, which folded the earlier separate reporting key into + one admin key), or mint a dedicated management credential given these routes mutate secret stores? - **Read-back & redaction:** can values be read back (never secrets in plaintext?), and what's audited? - Where the **routing policy** itself is declared — app definition vs API config. +## Alternatives Considered +- **Put routing logic in the CLI.** Rejected explicitly: it would diverge from any future GUI; the + routing decision must live once, in the admin API, so every front-end stays consistent. +- **A dedicated non-admin secrets service/endpoint family.** Rejected: reuses none of the established + admin-auth/admin-operation surface and adds a second security perimeter to harden. +- **Per-tier bespoke commands with no routing map.** Rejected: the routing map is what makes "unified + 1Password" and mixed per-env setups a config change rather than new code. + ## Related - [ADR-027: Configuration & Secrets — Model & Tiers](./027-configuration-and-secrets-model.md) - [ADR-028: Secrets & Config Provider Plugin](./028-secrets-config-provider-plugin.md) -- ADR-005 (Admin Script Runner — admin auth/surface), ADR-010 (admin operations / `/api/v2/admin`). +- [ADR-030: Configuration & Secrets — Docs & Adopter Maturation](./030-configuration-secrets-docs-and-maturation.md) +- ADR-005 (Admin Script Runner — admin auth/surface), ADR-010 (admin operations / admin API key). diff --git a/docs/architecture-decisions/030-configuration-secrets-docs-and-maturation.md b/docs/architecture-decisions/030-configuration-secrets-docs-and-maturation.md index 8c70f99c3..ed819614c 100644 --- a/docs/architecture-decisions/030-configuration-secrets-docs-and-maturation.md +++ b/docs/architecture-decisions/030-configuration-secrets-docs-and-maturation.md @@ -1,10 +1,10 @@ # ADR-030: Configuration & Secrets — Docs & Adopter Maturation -**Status**: Draft +**Status**: Proposed **Date**: 2026-07-10 **Deciders**: Sean Matthews -> Draft. The enablement half of the Configuration & Secrets work (model = ADR-027, provider = +> Proposed. The enablement half of the Configuration & Secrets work (model = ADR-027, provider = > ADR-028, management = ADR-029). Covers how we document it for adopters and how a Frigg app is > expected to grow over time. The full cross-pattern worked example is a **post-implementation doc > deliverable**; an illustrative target-state version is included below. @@ -66,9 +66,11 @@ token** (tier-3 per-connection). | HubSpot **access token** (per-conn.) | DB `Credential`, on demand | DB `Credential`, on demand | DB `Credential`, on demand | DB `Credential`, on demand | | Routing map | — (all pipeline) | `{platform: aws, appModule: aws, perConnection: database}` | `{platform: 1password, appModule: 1password, perConnection: database}` | `{platform: gcp, appModule: gcp, perConnection: database}` | -Two things the example makes obvious: **per-connection creds stay in the DB in every pattern** (the -invariant holds regardless of provider), and **moving from A→B→C is a routing-map + storage change, -not an app-code change.** +Two things the example makes obvious: **per-connection creds stay in the DB across every provider +pattern above** (the isolation invariant holds regardless of which cloud/secret store backs tiers 1–2) +— note this is the provider axis; whether a connection is per-end-user or an admin-authored **global +entity** (ADR-024) is a separate adoption choice, and both still live in the DB — and **moving from +A→B→C is a routing-map + storage change, not an app-code change.** ## Consequences @@ -82,8 +84,17 @@ not an app-code change.** ### Neutral - Establishes the maturation levels as the shared vocabulary for docs, CLI help, and onboarding. +## Alternatives Considered +- **Fold this guidance into ADR-027 as a section rather than its own ADR.** Rejected: the enablement/ + maturation story and the cross-pattern worked example are substantial and adopter-facing; a dedicated + ADR keeps ADR-027 focused on the model and gives docs a single anchor to track. +- **Write the full cross-pattern worked example now.** Deferred: the faithful version depends on the + shipped shapes of ADR-027–029, so an illustrative target-state table is included and the complete + example is called out as a post-implementation deliverable. + ## Related - [ADR-027](./027-configuration-and-secrets-model.md) (model & maturation levels), [ADR-028](./028-secrets-config-provider-plugin.md) (providers), [ADR-029](./029-variable-secret-management.md) (management). +- [ADR-024: Global Entities](./024-global-entities.md) (per-connection vs global adoption choice). - Frigg docs site (`docs/`) — target home for the guide. diff --git a/docs/architecture-decisions/README.md b/docs/architecture-decisions/README.md index 3c2959364..bb5fc22f2 100644 --- a/docs/architecture-decisions/README.md +++ b/docs/architecture-decisions/README.md @@ -43,10 +43,10 @@ An ADR documents a significant architectural decision made in the project, inclu | [024](./024-global-entities.md) | Global Entities | Proposed | 2024-12-18 | | [025](./025-agent-harness.md) | Agent Harness | Proposed | 2026-06-09 | | [026](./026-evals.md) | Evals | Proposed | 2026-06-09 | -| [027](./027-configuration-and-secrets-model.md) | Configuration & Secrets — Model & Tiers | Draft | 2026-07-10 | -| [028](./028-secrets-config-provider-plugin.md) | Secrets & Config Provider Plugin | Draft | 2026-07-10 | -| [029](./029-variable-secret-management.md) | Variable & Secret Management (Admin API / CLI / GUI) | Draft | 2026-07-10 | -| [030](./030-configuration-secrets-docs-and-maturation.md) | Configuration & Secrets — Docs & Adopter Maturation | Draft | 2026-07-10 | +| [027](./027-configuration-and-secrets-model.md) | Configuration & Secrets — Model & Tiers | Proposed | 2026-07-10 | +| [028](./028-secrets-config-provider-plugin.md) | Secrets & Config Provider Plugin | Proposed | 2026-07-10 | +| [029](./029-variable-secret-management.md) | Variable & Secret Management — Admin API, CLI, GUI | Proposed | 2026-07-10 | +| [030](./030-configuration-secrets-docs-and-maturation.md) | Configuration & Secrets — Docs & Adopter Maturation | Proposed | 2026-07-10 | ## Conventions