Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions docs/SPEC-EXTENSIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -1693,3 +1693,58 @@ x-streaming:
statuses: [live, pending]
primary_key: event_id
```

## `auth.credential_resolvers`

Selects which secret-manager resolvers a printed CLI compiles in for the
client-profile mechanism (`platform.SourceProfile`, `--credential-ref`).

```yaml
auth:
type: bearer_token
credential_resolvers: [bitwarden, file]
```

| Name | Scheme | Reference form | Needs |
|---|---|---|---|
| `file` | `file://` | `file:///absolute/path/to/secret` | nothing |
| `bitwarden` | `bws://` | `bws://<secret-id>` or `bws://<project-id>/<secret-key>` | `bws` on PATH |
| `onepassword` | `op://` | `op://<vault>/<item>/<field>` | `1password-pp-cli` on PATH |

**Omitted, this defaults to `[file]`** — the one resolver that depends on
nothing installed. No secret manager is built in.

Before this field existed, every printed CLI shipped a 1Password resolver and a
`ValidateCredentialReference` that rejected anything not starting with `op://`.
That made the whole client-profile mechanism unusable without 1Password, and
left a subprocess call to a password manager in the tree of every CLI whose
operator had never heard of it. Choosing a resolver is now a deliberate spec
decision, and choosing none but `file` is a perfectly good answer.

An unknown name is a spec validation error, not a silent skip: a spec that asks
for a resolver and does not get it produces a CLI that fails at credential-read
time, which is the worst moment to discover a typo.

### Adding a secret manager

The validator, the registry and the dispatcher are vendor-agnostic. Adding one is:

1. A template `internal/generator/templates/platform_resolver_<name>.go.tmpl`
that declares a `CredentialScheme`, implements `CredentialResolver`, and
registers both together from `init()` via `RegisterCredentialResolver`.
2. An entry in `credentialResolverTemplates` in
`internal/spec/credential_resolvers.go`.

Nothing else changes. Two rules for the resolver itself:

- **Never put a credential value in argv.** Only the non-secret reference and
the operator-chosen binary name. Subprocess-backed resolvers should call the
shared `runCredentialCommand` helper, which enforces this.
- **Never include the provider's stderr in an error.** A secret manager may echo
the resolved value while reporting an unrelated failure, and error strings
travel into logs and run receipts.

If a scheme's reference is not a sequence of opaque identifiers — a filesystem
path, say — set `CredentialScheme.Validate` and supply its own rules. The
generic `MinParts`/`MaxParts` component checks reject a leading empty component,
which every absolute path has.
11 changes: 11 additions & 0 deletions internal/generator/config_paths_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,17 @@ func TestConfigSaveFailureLeavesOriginalParseable(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("directory permission failure is POSIX-specific")
}
// This test makes save() fail by removing write permission from the config
// directory. Root ignores that: CAP_DAC_OVERRIDE bypasses the DAC check, so
// the temp file is created anyway, save() succeeds, and the assertion below
// fails for a reason that has nothing to do with the code under test.
//
// Same category as the Windows skip above — the mechanism the test depends
// on does not exist in this environment. Skipping is honest; weakening the
// assertion to accept a nil error would delete the coverage everywhere.
if os.Geteuid() == 0 {
t.Skip("running as root: a read-only directory cannot make the write fail")
}
home, _ := resetPathTestEnv(t)
configPath := filepath.Join(home, "atomic", "config.toml")
configDir := filepath.Dir(configPath)
Expand Down
16 changes: 16 additions & 0 deletions internal/generator/generator.go
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,13 @@ func New(s *spec.APISpec, outputDir string) *Generator {
templates: make(map[string]*template.Template),
}
g.funcs = template.FuncMap{
// credentialRefFixture yields a synthetic credential reference valid
// under the resolvers this spec selected, so generated conformance
// tests do not hardcode one vendor's syntax. Takes a name so distinct
// fixtures stay distinct.
"credentialRefFixture": func(name string) string {
return spec.SyntheticCredentialReference(g.Spec.Auth, name)
},
"title": cases.Title(language.English).String,
"lower": strings.ToLower,
"upper": strings.ToUpper,
Expand Down Expand Up @@ -2440,6 +2447,15 @@ func (g *Generator) renderSingleFiles() error {
}
maps.Copy(singleFiles, cobratreeWalkerTemplateFiles())

// Credential resolvers are emitted per spec (auth.credential_resolvers), so a
// printed CLI carries only the secret managers its operator actually uses.
// Before this, every CLI shipped a 1Password resolver and an op://-only
// reference validator whether or not 1Password was in play.
for _, tmplName := range g.Spec.Auth.CredentialResolverTemplates() {
outName := strings.TrimSuffix(strings.TrimPrefix(tmplName, "platform_"), ".tmpl")
singleFiles[tmplName] = filepath.Join("internal", "platform", outName)
}

for tmplName, outPath := range singleFiles {
if tmplName == "types.go.tmpl" && g.shouldPreserveExistingTypesFile(outPath) {
continue
Expand Down
10 changes: 7 additions & 3 deletions internal/generator/generator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ func TestGenerateProjectsCompile(t *testing.T) {
mustInclude := []string{
"go.mod",
"Makefile",
// The default credential resolver. A spec selecting other resolvers
// (auth.credential_resolvers) adds files beside this one, but `file` is
// what a spec that says nothing gets, so every fixture ships it.
filepath.Join("internal", "platform", "resolver_file.go"),
"AGENTS.md",
"CLAUDE.md",
"README.md",
Expand Down Expand Up @@ -139,9 +143,9 @@ func TestGenerateProjectsCompile(t *testing.T) {
// +2: command and MCP platform-window adoption plus conformance tests.
// +1: internal/cliutil/testenv, the sandbox helper every emitted test
// routes its HOME/USERPROFILE isolation through.
{name: "stytch", specPath: filepath.Join("..", "..", "testdata", "stytch.yaml"), expectedFiles: 167},
{name: "clerk", specPath: filepath.Join("..", "..", "testdata", "clerk.yaml"), expectedFiles: 171},
{name: "loops", specPath: filepath.Join("..", "..", "testdata", "loops.yaml"), expectedFiles: 169},
{name: "stytch", specPath: filepath.Join("..", "..", "testdata", "stytch.yaml"), expectedFiles: 168},
{name: "clerk", specPath: filepath.Join("..", "..", "testdata", "clerk.yaml"), expectedFiles: 172},
{name: "loops", specPath: filepath.Join("..", "..", "testdata", "loops.yaml"), expectedFiles: 170},
}

for _, tt := range tests {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ func platformRateLimitTestClient(t *testing.T, server *httptest.Server) (*Client
SchemaVersion: platform.ProfileSchemaVersion,
Name: "tenant-a",
Sources: map[string]platform.SourceProfile{
"{{.Name}}": {CredentialRef: "op://Test Vault/Synthetic/token", ExpectedAccountID: "acct-a", ExpectedOrgName: "Tenant A", ExpectedStoreID: "store-a", ExpectedStoreDomain: "tenant-a.example.test", ExpectedPropertyID: "123", ExpectedBaseURL: "https://tenant-a.example.test"},
"{{.Name}}": {CredentialRef: "{{credentialRefFixture "token"}}", ExpectedAccountID: "acct-a", ExpectedOrgName: "Tenant A", ExpectedStoreID: "store-a", ExpectedStoreDomain: "tenant-a.example.test", ExpectedPropertyID: "123", ExpectedBaseURL: "https://tenant-a.example.test"},
},
}, "{{.Name}}-pp-cli", "{{.Name}}")
if err != nil {
Expand Down
12 changes: 8 additions & 4 deletions internal/generator/templates/platform_cli.go.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,11 @@ func registerPlatformSource(registration platformSourceRegistration) {
}

var platformResolverFactory = func() platform.CredentialResolver {
return platform.OnePasswordResolver{Binary: strings.TrimSpace(os.Getenv("PRINTING_PRESS_ONEPASSWORD_CLI"))}
// Dispatches to whichever resolvers this CLI was built with; see
// auth.credential_resolvers in the spec. No secret manager is privileged,
// and a CLI built with none rejects every credential reference with an
// error saying so.
return platform.RegistryResolver{}
}

func preparePlatformSession(flags *rootFlags) error {
Expand Down Expand Up @@ -363,9 +367,9 @@ type platformSourceFlags struct {
}

func (values *platformSourceFlags) addFlags(command *cobra.Command) {
command.Flags().StringVar(&values.credentialRef, "credential-ref", "", "Exact op:// credential reference")
command.Flags().StringVar(&values.usernameRef, "username-ref", "", "Exact op:// username reference")
command.Flags().StringToStringVar(&values.additionalCredentialRef, "additional-credential-ref", nil, "Named exact op:// credential references (name=reference)")
command.Flags().StringVar(&values.credentialRef, "credential-ref", "", "Exact credential reference, e.g. "+platform.CredentialReferenceHint())
command.Flags().StringVar(&values.usernameRef, "username-ref", "", "Exact username reference, e.g. "+platform.CredentialReferenceHint())
command.Flags().StringToStringVar(&values.additionalCredentialRef, "additional-credential-ref", nil, "Named exact credential references (name=reference), e.g. "+platform.CredentialReferenceHint())
command.Flags().StringVar(&values.expectedAccountID, "expected-account-id", "", "Trusted immutable provider account ID")
command.Flags().StringVar(&values.expectedOrgName, "expected-org-name", "", "Exact expected organization name")
command.Flags().StringVar(&values.expectedStoreID, "expected-store-id", "", "Trusted immutable Shopify store ID")
Expand Down
20 changes: 10 additions & 10 deletions internal/generator/templates/platform_cli_test.go.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,10 @@ func TestPlatformSourceFlagsPreserveNamedExactReferences(t *testing.T) {
t.Fatalf("empty named references must stay nil for migration idempotence: %#v", source.AdditionalCredentialRefs)
}
values := platformSourceFlags{
credentialRef: "op://Test Vault/Test Item/token",
credentialRef: "{{credentialRefFixture "token"}}",
additionalCredentialRef: map[string]string{
"client_id": "op://Test Vault/Test Item/client-id",
"secret": "op://Test Vault/Test Item/secret",
"client_id": "{{credentialRefFixture "client-id"}}",
"secret": "{{credentialRefFixture "secret"}}",
},
}
source := values.sourceProfile()
Expand Down Expand Up @@ -136,7 +136,7 @@ func TestPlatformCLIConformanceSurfaceAndGate(t *testing.T) {
SchemaVersion: platform.ProfileSchemaVersion,
Name: "tenant-a",
Sources: map[string]platform.SourceProfile{
"test-source": {CredentialRef: "op://Test Vault/Test Item/token", ExpectedAccountID: "acct-a"},
"test-source": {CredentialRef: "{{credentialRefFixture "token"}}", ExpectedAccountID: "acct-a"},
},
}
if err := platform.SaveProfile(profile); err != nil {
Expand Down Expand Up @@ -210,7 +210,7 @@ func TestPlatformCLIConformanceOutputMetadataAndAnalytics(t *testing.T) {
SchemaVersion: platform.ProfileSchemaVersion,
Name: "tenant-a",
Sources: map[string]platform.SourceProfile{
"test-source": {CredentialRef: "op://Test Vault/Test Item/token", ExpectedAccountID: "acct-a"},
"test-source": {CredentialRef: "{{credentialRefFixture "token"}}", ExpectedAccountID: "acct-a"},
},
}
session, err := platform.PrepareProfileSourceFromConfig(profile, "sample-pp-cli", "test-source")
Expand Down Expand Up @@ -365,7 +365,7 @@ func TestPlatformCLIConformanceMismatchFailsBeforeCommand(t *testing.T) {
t.Setenv("PRINTING_PRESS_CLIENT_PROFILE", "")
if err := platform.SaveProfile(&platform.Profile{
SchemaVersion: platform.ProfileSchemaVersion, Name: "tenant-a",
Sources: map[string]platform.SourceProfile{"test-source": {CredentialRef: "op://Test Vault/Test Item/token", ExpectedAccountID: "acct-a"}},
Sources: map[string]platform.SourceProfile{"test-source": {CredentialRef: "{{credentialRefFixture "token"}}", ExpectedAccountID: "acct-a"}},
}); err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -411,7 +411,7 @@ func TestPlatformMigrationIsVerifiedIdempotentAndQuarantinesLegacyState(t *testi
return conformanceResolver{value: []byte("migration-secret-never-print")}
}

args := []string{"client", "migrate", "tenant-a", "--credential-ref", "op://Test Vault/Test Item/token", "--expected-account-id", "acct-a", "--legacy-db", legacyDB, "--receipt-file", receiptPath, "--json"}
args := []string{"client", "migrate", "tenant-a", "--credential-ref", "{{credentialRefFixture "token"}}", "--expected-account-id", "acct-a", "--legacy-db", legacyDB, "--receipt-file", receiptPath, "--json"}
root := RootCmd()
var output bytes.Buffer
root.SetOut(&output)
Expand Down Expand Up @@ -458,7 +458,7 @@ func TestPlatformMigrationIsVerifiedIdempotentAndQuarantinesLegacyState(t *testi
}

conflict := RootCmd()
conflict.SetArgs([]string{"client", "migrate", "tenant-a", "--credential-ref", "op://Test Vault/Test Item/token", "--expected-account-id", "acct-b"})
conflict.SetArgs([]string{"client", "migrate", "tenant-a", "--credential-ref", "{{credentialRefFixture "token"}}", "--expected-account-id", "acct-b"})
if err := conflict.Execute(); err == nil || !strings.Contains(err.Error(), "refusing to overwrite") {
t.Fatalf("conflicting migration error = %v", err)
}
Expand Down Expand Up @@ -501,7 +501,7 @@ func TestPlatformMigrationAdoptsOnlyVerifiedTenantDatabase(t *testing.T) {
var output bytes.Buffer
root.SetOut(&output)
root.SetErr(&output)
migrationArgs := []string{"client", "migrate", "tenant-adopt", "--credential-ref", "op://Test Vault/Test Item/token", "--expected-account-id", "acct-a", "--legacy-db", legacyDB, "--json"}
migrationArgs := []string{"client", "migrate", "tenant-adopt", "--credential-ref", "{{credentialRefFixture "token"}}", "--expected-account-id", "acct-a", "--legacy-db", legacyDB, "--json"}
root.SetArgs(migrationArgs)
if err := root.Execute(); err != nil {
t.Fatal(err)
Expand Down Expand Up @@ -574,7 +574,7 @@ func TestPlatformMigrationWrongTenantCannotCreateProfile(t *testing.T) {
root := RootCmd()
root.SetOut(&bytes.Buffer{})
root.SetErr(&bytes.Buffer{})
root.SetArgs([]string{"client", "migrate", "tenant-rejected", "--credential-ref", "op://Test Vault/Test Item/token", "--expected-account-id", "acct-a", "--json"})
root.SetArgs([]string{"client", "migrate", "tenant-rejected", "--credential-ref", "{{credentialRefFixture "token"}}", "--expected-account-id", "acct-a", "--json"})
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "tenant gate mismatch") {
t.Fatalf("wrong-tenant migration error = %v", err)
}
Expand Down
22 changes: 13 additions & 9 deletions internal/generator/templates/platform_conformance_test.go.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ func validKlaviyoProfile(name string) *Profile {
Name: name,
Sources: map[string]SourceProfile{
"klaviyo": {
CredentialRef: "op://Synthetic/Klaviyo/private-api-key",
CredentialRef: "{{credentialRefFixture "private-api-key"}}",
ExpectedAccountID: "account-one",
ExpectedOrgName: "Synthetic Org",
},
Expand All @@ -87,7 +87,11 @@ func TestProfileParserAndSelectionConformance(t *testing.T) {
t.Fatalf("profile mode = %v, err = %v", info.Mode().Perm(), err)
}

for _, bad := range []string{"literal-secret", "$TOKEN", "${TOKEN}", "file:///tmp/key", "op://vault/item", "op://vault//field"} {
// Unsafe or unregistered in every resolver configuration: a bare literal, a
// shell interpolation, and references in a scheme this CLI was not built
// with. Do not add a well-formed reference in a scheme that MIGHT be
// selected — what is valid here depends on auth.credential_resolvers.
for _, bad := range []string{"literal-secret", "$TOKEN", "${TOKEN}", "made-up://vault/item", " {{credentialRefFixture "leading-space"}}"} {
if err := ValidateCredentialReference(bad); err == nil {
t.Fatalf("unsafe credential reference accepted: %q", bad)
}
Expand Down Expand Up @@ -118,7 +122,7 @@ func TestProfileRejectsUnknownAndDuplicateNormalizedSources(t *testing.T) {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
unknown := []byte("schema_version = 1\nname = \"synthetic\"\nunknown = true\n[sources.klaviyo]\ncredential_ref = \"op://Synthetic/Klaviyo/key\"\nexpected_account_id = \"a\"\n")
unknown := []byte("schema_version = 1\nname = \"synthetic\"\nunknown = true\n[sources.klaviyo]\ncredential_ref = \"{{credentialRefFixture "klaviyo-key"}}\"\nexpected_account_id = \"a\"\n")
if err := os.WriteFile(path, unknown, 0o600); err != nil {
t.Fatal(err)
}
Expand Down Expand Up @@ -244,8 +248,8 @@ func TestGorgiasUsesDocumentedBaseURLIdentityOnly(t *testing.T) {
func TestProviderMismatchAndDatabaseOwnershipConformance(t *testing.T) {
setPlatformRoots(t)
profile := &Profile{SchemaVersion: ProfileSchemaVersion, Name: "tenant-a", Sources: map[string]SourceProfile{
"shopify": {CredentialRef: "op://Synthetic/Shopify/token", ExpectedStoreID: "gid://shopify/Shop/1", ExpectedStoreDomain: "tenant-a.myshopify.com"},
"gorgias": {CredentialRef: "op://Synthetic/Gorgias/key", UsernameRef: "op://Synthetic/Gorgias/user", ExpectedBaseURL: "https://tenant-a.gorgias.com/api"},
"shopify": {CredentialRef: "{{credentialRefFixture "token"}}", ExpectedStoreID: "gid://shopify/Shop/1", ExpectedStoreDomain: "tenant-a.myshopify.com"},
"gorgias": {CredentialRef: "{{credentialRefFixture "key"}}", UsernameRef: "{{credentialRefFixture "user"}}", ExpectedBaseURL: "https://tenant-a.gorgias.com/api"},
}}
if err := SaveProfile(profile); err != nil {
t.Fatal(err)
Expand Down Expand Up @@ -288,8 +292,8 @@ func TestCrossSourceDatabaseRequiresSelectedProfileOwnership(t *testing.T) {
SchemaVersion: ProfileSchemaVersion,
Name: "tenant-a",
Sources: map[string]SourceProfile{
"meta-ads": {CredentialRef: "op://Synthetic/Meta/token", ExpectedAccountID: "meta-one"},
"shopify": {CredentialRef: "op://Synthetic/Shopify/token", ExpectedStoreID: "shop-one", ExpectedStoreDomain: "tenant-a.myshopify.com"},
"meta-ads": {CredentialRef: "{{credentialRefFixture "token"}}", ExpectedAccountID: "meta-one"},
"shopify": {CredentialRef: "{{credentialRefFixture "token"}}", ExpectedStoreID: "shop-one", ExpectedStoreDomain: "tenant-a.myshopify.com"},
},
}
primary, err := PrepareProfileSourceFromConfig(profile, "meta-ads-pp-cli", "meta-ads")
Expand Down Expand Up @@ -331,7 +335,7 @@ func TestCrossSourceDatabaseRequiresSelectedProfileOwnership(t *testing.T) {
tamperedProfile := *profile
tamperedProfile.Sources = map[string]SourceProfile{
"meta-ads": profile.Sources["meta-ads"],
"shopify": {CredentialRef: "op://Synthetic/Shopify/token", ExpectedStoreID: "shop-two", ExpectedStoreDomain: "tenant-b.myshopify.com"},
"shopify": {CredentialRef: "{{credentialRefFixture "token"}}", ExpectedStoreID: "shop-two", ExpectedStoreDomain: "tenant-b.myshopify.com"},
}
tampered.Profile = &tamperedProfile
if err := VerifyCrossSourceTenantMetadataReadOnly(context.Background(), db, &tampered, "shopify"); err == nil {
Expand Down Expand Up @@ -496,7 +500,7 @@ func TestContextCarriesOutputMetadataRecorder(t *testing.T) {
SchemaVersion: ProfileSchemaVersion,
Name: "synthetic",
Sources: map[string]SourceProfile{
"sample": {CredentialRef: "op://Test Vault/Test Item/token", ExpectedAccountID: "one"},
"sample": {CredentialRef: "{{credentialRefFixture "token"}}", ExpectedAccountID: "one"},
},
}
session, err := PrepareProfileSourceFromConfig(profile, "sample-pp-cli", "sample")
Expand Down
Loading
Loading