Add Notarius credential configuration
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# Notarius Credential Forwarding Implementation Plan
|
# Notarius Credential Forwarding Implementation Plan
|
||||||
|
|
||||||
Status: Not started
|
Status: In progress — 2026-09-16
|
||||||
|
|
||||||
Implement the [feature roadmap](notarius-credential-forwarding.md) in the order
|
Implement the [feature roadmap](notarius-credential-forwarding.md) in the order
|
||||||
below. Each stage is a bounded assignment for a gpt-5.6-terra coding agent.
|
below. Each stage is a bounded assignment for a gpt-5.6-terra coding agent.
|
||||||
@@ -50,7 +50,21 @@ ConfigKit upgrade plan is preserved in Git history.
|
|||||||
|
|
||||||
## Stage 1 — Add typed configuration and safe projections
|
## Stage 1 — Add typed configuration and safe projections
|
||||||
|
|
||||||
Status: Not started
|
Status: Complete — 2026-09-16
|
||||||
|
|
||||||
|
Completion: Added the optional typed Notarius credential reference and a safe
|
||||||
|
direct-YAML projection. Existing ConfigKit loading, effective projections,
|
||||||
|
schema-derived provenance, normalization copies, and effective digests carry
|
||||||
|
the field without exposing its resolved value. Coverage now protects omitted
|
||||||
|
and null values, custom and invalid references, disabled-section resolution,
|
||||||
|
profile replacement/removal, root/profile ownership, safe YAML/JSON output, and
|
||||||
|
reference-name versus value-rotation digest behavior.
|
||||||
|
|
||||||
|
Checks:
|
||||||
|
|
||||||
|
- `GOWORK=off go test ./internal/config` — pass.
|
||||||
|
- `GOWORK=off go test ./...` — pass; maintained examples load and validate.
|
||||||
|
- `git diff --check` — pass.
|
||||||
|
|
||||||
Read: `internal/config/config.go`, `credential_projection.go`,
|
Read: `internal/config/config.go`, `credential_projection.go`,
|
||||||
`effective_projection.go`, `effective_pipeline.go`, `effective_digest.go`,
|
`effective_projection.go`, `effective_pipeline.go`, `effective_digest.go`,
|
||||||
|
|||||||
@@ -309,6 +309,7 @@ type ScriptoriumInputConfig struct {
|
|||||||
type NotariusConfig struct {
|
type NotariusConfig struct {
|
||||||
Enabled bool `yaml:"enabled" json:"enabled"`
|
Enabled bool `yaml:"enabled" json:"enabled"`
|
||||||
Binary string `yaml:"binary" json:"binary"`
|
Binary string `yaml:"binary" json:"binary"`
|
||||||
|
LLMAPIKey *configkit.Secret `yaml:"llm_api_key,omitempty" json:"llm_api_key,omitempty"`
|
||||||
ConfigPath string `yaml:"config_path" json:"config_path"`
|
ConfigPath string `yaml:"config_path" json:"config_path"`
|
||||||
PipelineID string `yaml:"pipeline_id" json:"pipeline_id"`
|
PipelineID string `yaml:"pipeline_id" json:"pipeline_id"`
|
||||||
Timeout string `yaml:"timeout" json:"timeout"`
|
Timeout string `yaml:"timeout" json:"timeout"`
|
||||||
|
|||||||
@@ -50,6 +50,26 @@ func (cfg AuditaConfig) MarshalYAML() (any, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MarshalYAML exposes only the public Notarius credential reference name.
|
||||||
|
func (cfg NotariusConfig) MarshalYAML() (any, error) {
|
||||||
|
type safeNotarius struct {
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
Binary string `yaml:"binary"`
|
||||||
|
LLMAPIKey *string `yaml:"llm_api_key,omitempty"`
|
||||||
|
ConfigPath string `yaml:"config_path"`
|
||||||
|
PipelineID string `yaml:"pipeline_id"`
|
||||||
|
Timeout string `yaml:"timeout"`
|
||||||
|
WorkingDirectory string `yaml:"working_directory"`
|
||||||
|
References map[string]string `yaml:"references"`
|
||||||
|
Outputs map[string]NotariusOutputConfig `yaml:"outputs"`
|
||||||
|
}
|
||||||
|
return safeNotarius{
|
||||||
|
Enabled: cfg.Enabled, Binary: cfg.Binary, LLMAPIKey: secretReferenceName(cfg.LLMAPIKey),
|
||||||
|
ConfigPath: cfg.ConfigPath, PipelineID: cfg.PipelineID, Timeout: cfg.Timeout,
|
||||||
|
WorkingDirectory: cfg.WorkingDirectory, References: cfg.References, Outputs: cfg.Outputs,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func secretReferenceName(secret *configkit.Secret) *string {
|
func secretReferenceName(secret *configkit.Secret) *string {
|
||||||
if secret == nil {
|
if secret == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ func TestCredentialValuesNeverEnterEffectiveProjectionOrDigest(t *testing.T) {
|
|||||||
t.Setenv("NARRATIO_TEST_ACCESS_KEY", firstCanary)
|
t.Setenv("NARRATIO_TEST_ACCESS_KEY", firstCanary)
|
||||||
t.Setenv("NARRATIO_TEST_SECRET_KEY", firstCanary)
|
t.Setenv("NARRATIO_TEST_SECRET_KEY", firstCanary)
|
||||||
t.Setenv("NARRATIO_TEST_AUDITA_KEY", firstCanary)
|
t.Setenv("NARRATIO_TEST_AUDITA_KEY", firstCanary)
|
||||||
|
t.Setenv("NARRATIO_TEST_NOTARIUS_KEY", firstCanary)
|
||||||
pipelineYAML := strings.Replace(testPipelineBaseYAML, "audita:\n binary: audita\n", "audita:\n binary: audita\n llm_api_key: NARRATIO_TEST_AUDITA_KEY\n", 1) + `
|
pipelineYAML := strings.Replace(testPipelineBaseYAML, "audita:\n binary: audita\n", "audita:\n binary: audita\n llm_api_key: NARRATIO_TEST_AUDITA_KEY\n", 1) + `
|
||||||
storage:
|
storage:
|
||||||
backend: s3
|
backend: s3
|
||||||
@@ -99,6 +100,9 @@ storage:
|
|||||||
bucket: test-bucket
|
bucket: test-bucket
|
||||||
access_key_id: NARRATIO_TEST_ACCESS_KEY
|
access_key_id: NARRATIO_TEST_ACCESS_KEY
|
||||||
secret_access_key: NARRATIO_TEST_SECRET_KEY
|
secret_access_key: NARRATIO_TEST_SECRET_KEY
|
||||||
|
notarius:
|
||||||
|
enabled: false
|
||||||
|
llm_api_key: NARRATIO_TEST_NOTARIUS_KEY
|
||||||
`
|
`
|
||||||
pipelinePath, _ := writeConfigFiles(t, pipelineYAML, testSessionBaseYAML)
|
pipelinePath, _ := writeConfigFiles(t, pipelineYAML, testSessionBaseYAML)
|
||||||
|
|
||||||
@@ -130,7 +134,7 @@ storage:
|
|||||||
if strings.Contains(output, firstCanary) {
|
if strings.Contains(output, firstCanary) {
|
||||||
t.Fatalf("effective output exposed credential value: %q", output)
|
t.Fatalf("effective output exposed credential value: %q", output)
|
||||||
}
|
}
|
||||||
for _, reference := range []string{"NARRATIO_TEST_ACCESS_KEY", "NARRATIO_TEST_SECRET_KEY", "NARRATIO_TEST_AUDITA_KEY"} {
|
for _, reference := range []string{"NARRATIO_TEST_ACCESS_KEY", "NARRATIO_TEST_SECRET_KEY", "NARRATIO_TEST_AUDITA_KEY", "NARRATIO_TEST_NOTARIUS_KEY"} {
|
||||||
if !strings.Contains(output, reference) {
|
if !strings.Contains(output, reference) {
|
||||||
t.Fatalf("effective output omitted safe reference %q: %q", reference, output)
|
t.Fatalf("effective output omitted safe reference %q: %q", reference, output)
|
||||||
}
|
}
|
||||||
@@ -140,6 +144,7 @@ storage:
|
|||||||
t.Setenv("NARRATIO_TEST_ACCESS_KEY", secondCanary)
|
t.Setenv("NARRATIO_TEST_ACCESS_KEY", secondCanary)
|
||||||
t.Setenv("NARRATIO_TEST_SECRET_KEY", secondCanary)
|
t.Setenv("NARRATIO_TEST_SECRET_KEY", secondCanary)
|
||||||
t.Setenv("NARRATIO_TEST_AUDITA_KEY", secondCanary)
|
t.Setenv("NARRATIO_TEST_AUDITA_KEY", secondCanary)
|
||||||
|
t.Setenv("NARRATIO_TEST_NOTARIUS_KEY", secondCanary)
|
||||||
second, err := LoadPipeline(pipelinePath)
|
second, err := LoadPipeline(pipelinePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -156,6 +161,15 @@ storage:
|
|||||||
if EffectivePipelineDigest(first) == EffectivePipelineDigest(alternate) {
|
if EffectivePipelineDigest(first) == EffectivePipelineDigest(alternate) {
|
||||||
t.Fatal("credential reference-name change did not alter effective digest")
|
t.Fatal("credential reference-name change did not alter effective digest")
|
||||||
}
|
}
|
||||||
|
t.Setenv("NARRATIO_TEST_NOTARIUS_KEY_ALTERNATE", secondCanary)
|
||||||
|
alternateNotariusPath, _ := writeConfigFiles(t, strings.Replace(pipelineYAML, "NARRATIO_TEST_NOTARIUS_KEY", "NARRATIO_TEST_NOTARIUS_KEY_ALTERNATE", 1), testSessionBaseYAML)
|
||||||
|
alternateNotarius, err := LoadPipeline(alternateNotariusPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if EffectivePipelineDigest(first) == EffectivePipelineDigest(alternateNotarius) {
|
||||||
|
t.Fatal("Notarius credential reference-name change did not alter effective digest")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEffectivePipelineValuesPreserveNilEmptyAndExactIntegers(t *testing.T) {
|
func TestEffectivePipelineValuesPreserveNilEmptyAndExactIntegers(t *testing.T) {
|
||||||
|
|||||||
@@ -50,6 +50,72 @@ notarius:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNotariusCredentialReferenceLoading(t *testing.T) {
|
||||||
|
const envName = "NARRATIO_TEST_NOTARIUS_KEY"
|
||||||
|
const canary = "notarius-credential-canary"
|
||||||
|
t.Setenv(envName, canary)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
credential string
|
||||||
|
wantNil bool
|
||||||
|
}{
|
||||||
|
{name: "omitted", wantNil: true},
|
||||||
|
{name: "null", credential: " llm_api_key: null\n", wantNil: true},
|
||||||
|
{name: "custom reference", credential: " llm_api_key: " + envName + "\n"},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "pipeline.yml")
|
||||||
|
pipelineYAML := testPipelineBaseYAML + "\nnotarius:\n enabled: false\n" + test.credential
|
||||||
|
if err := os.WriteFile(path, []byte(pipelineYAML), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg, err := LoadPipeline(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if test.wantNil {
|
||||||
|
if cfg.Notarius.LLMAPIKey != nil {
|
||||||
|
t.Fatal("credential reference should be nil")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cfg.Notarius.LLMAPIKey == nil || cfg.Notarius.LLMAPIKey.EnvName() != envName ||
|
||||||
|
!cfg.Notarius.LLMAPIKey.IsResolved() || cfg.Notarius.LLMAPIKey.Value() != canary {
|
||||||
|
t.Fatal("credential reference was not loaded and resolved")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotariusCredentialReferenceFailures(t *testing.T) {
|
||||||
|
const missing = "NARRATIO_TEST_NOTARIUS_MISSING"
|
||||||
|
if err := os.Unsetenv(missing); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name, reference, want string
|
||||||
|
}{
|
||||||
|
{name: "missing", reference: missing, want: "secret.unset"},
|
||||||
|
{name: "empty", reference: `""`, want: "secret.invalid_name"},
|
||||||
|
{name: "invalid", reference: "123-NOT-AN-ENV-NAME", want: "secret.invalid_name"},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "pipeline.yml")
|
||||||
|
pipelineYAML := testPipelineBaseYAML + "\nnotarius:\n enabled: false\n llm_api_key: " + test.reference + "\n"
|
||||||
|
if err := os.WriteFile(path, []byte(pipelineYAML), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err := LoadPipeline(path)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), test.want) {
|
||||||
|
t.Fatalf("LoadPipeline() error = %v, want classification %q", err, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNotariusEnabledDefaultsAndPathResolution(t *testing.T) {
|
func TestNotariusEnabledDefaultsAndPathResolution(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
pipelinePath := filepath.Join(dir, "deployment", "pipeline.yml")
|
pipelinePath := filepath.Join(dir, "deployment", "pipeline.yml")
|
||||||
|
|||||||
@@ -117,6 +117,71 @@ func TestPipelineProfileDoesNotRequireUnusedBaseCredentials(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNotariusCredentialProfileReplacementAndProvenance(t *testing.T) {
|
||||||
|
const baseKey = "NARRATIO_NOTARIUS_UNUSED_BASE_KEY"
|
||||||
|
const profileKey = "NARRATIO_NOTARIUS_PROFILE_KEY"
|
||||||
|
if err := os.Unsetenv(baseKey); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Setenv(profileKey, "synthetic-notarius-profile-key")
|
||||||
|
|
||||||
|
for _, credential := range []string{profileKey, "null"} {
|
||||||
|
t.Run(credential, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
root := writePipelineFixture(t, dir, "pipeline.yml", "whisperx:\n transcribe_url: https://transcription.example.test/transcribe\nnotarius:\n enabled: false\n llm_api_key: "+baseKey+"\n")
|
||||||
|
overlay := writePipelineFixture(t, dir, "profiles/testing.yml", "notarius:\n llm_api_key: "+credential+"\n")
|
||||||
|
profile := "testing"
|
||||||
|
cfg, err := LoadPipelineWithOptions(root, PipelineLoadOptions{Profile: &profile})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if credential == "null" {
|
||||||
|
if cfg.Notarius.LLMAPIKey != nil {
|
||||||
|
t.Fatal("profile did not remove the base credential")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if cfg.Notarius.LLMAPIKey == nil || cfg.Notarius.LLMAPIKey.EnvName() != profileKey || !cfg.Notarius.LLMAPIKey.IsResolved() {
|
||||||
|
t.Fatal("profile credential was not resolved")
|
||||||
|
}
|
||||||
|
sources, err := EffectivePipelineSources(cfg, ResolvedParty{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, source := range sources {
|
||||||
|
if source.Path == "notarius.llm_api_key" {
|
||||||
|
found = source.Source == overlay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("Notarius credential provenance did not identify the selected profile")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotariusCredentialRootProvenance(t *testing.T) {
|
||||||
|
const envName = "NARRATIO_NOTARIUS_ROOT_KEY"
|
||||||
|
t.Setenv(envName, "synthetic-notarius-root-key")
|
||||||
|
dir := t.TempDir()
|
||||||
|
root := writePipelineFixture(t, dir, "pipeline.yml", "whisperx:\n transcribe_url: https://transcription.example.test/transcribe\nnotarius:\n enabled: false\n llm_api_key: "+envName+"\n")
|
||||||
|
cfg, err := LoadPipeline(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sources, err := EffectivePipelineSources(cfg, ResolvedParty{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, source := range sources {
|
||||||
|
if source.Path == "notarius.llm_api_key" && source.Source == root {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatal("Notarius credential provenance did not identify the root pipeline")
|
||||||
|
}
|
||||||
|
|
||||||
func TestPipelineConfigKitAllowsOneBaseDeclarationWithProfileOverride(t *testing.T) {
|
func TestPipelineConfigKitAllowsOneBaseDeclarationWithProfileOverride(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
writePipelineFixture(t, dir, "pipeline.yml", `configkit:
|
writePipelineFixture(t, dir, "pipeline.yml", `configkit:
|
||||||
|
|||||||
Reference in New Issue
Block a user