From 2a3bd64d61da019f5b825db7dfa86eed1205f311 Mon Sep 17 00:00:00 2001 From: Squire as Brandon High <759848+highb@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:19:25 +0000 Subject: [PATCH 01/16] docs: define secret output declaration verification contract Co-authored-by: c1-squire-dev[bot] --- .../connector-secret-types/evidence.md | 3 ++ .../connector-secret-types/plan.md | 36 +++++++++++++++++++ 2 files changed, 39 insertions(+) create mode 100644 docs/verification/connector-secret-types/evidence.md create mode 100644 docs/verification/connector-secret-types/plan.md diff --git a/docs/verification/connector-secret-types/evidence.md b/docs/verification/connector-secret-types/evidence.md new file mode 100644 index 000000000..f94a4e6e2 --- /dev/null +++ b/docs/verification/connector-secret-types/evidence.md @@ -0,0 +1,3 @@ +# Evidence ledger + +Base: released v0.42.3, commit 2e045d482d592a9462b5f87c70a6113fe87ec54a. Schema inspection found no per-secret content type declaration on config.Field or BatonActionSchema. No runtime change is implemented in this plan commit. Architecture disposition and execution receipts remain pending. diff --git a/docs/verification/connector-secret-types/plan.md b/docs/verification/connector-secret-types/plan.md new file mode 100644 index 000000000..9948ead31 --- /dev/null +++ b/docs/verification/connector-secret-types/plan.md @@ -0,0 +1,36 @@ +# Connector-declared secret action output types + +## Frozen behavior + +A connector declares the content type of each secret action output before its handler runs. The connector owns the value bytes and codec. The caller chooses the destination and acknowledges the declared content type before producer side effects. One secret output can contain a document with multiple fields. + +An absent declaration preserves existing generic output behavior. A present empty declaration, declarations on inputs or public outputs, and malformed identifiers are invalid. Existing arbitrary encryption context behavior remains compatible for undeclared/generic outputs. Explicit structured outputs require a matching typed destination acknowledgement before action allocation and handler execution; an older caller that supplies generic context cannot silently reinterpret a structured output. + +The action schema at registration and invocation is an immutable snapshot. Later caller mutations of schema pointers or encryption configurations cannot change an invocation's declaration or authenticated context. + +Secret bytes remain separate from public return fields and are encrypted without conversion. This change introduces no credential issuance protocol, typed input support, provider lifecycle, release or activation. + +## Risk and verification + +HIGH: a mixed-version caller can otherwise silently change durable payload meaning after a producer performs an external effect. Prevention must run before the handler; post-result checking cannot reverse that effect. No grant-scale or artifact-open hot path changes: checks are linear in schema fields and encryption configurations, with a bounded context parse only for explicit typed outputs. + +| Criterion | Required instrument | Initial status | +| --- | --- | --- | +| Declaration absent/present round-trip | Descriptor and binary/JSON schema round-trip | Evidence incomplete | +| Invalid placement/value refused | Global/resource registration tables; assert no schema published | Evidence incomplete | +| Old generic caller refuses typed output | Invoke actual ActionManager with generic/missing/wrong context; producer-call count zero | Evidence incomplete | +| Matching typed context reaches producer | Actual manager, real JWE recipient and encrypted structured bytes | Evidence incomplete | +| Legacy generic compatibility | Existing secret-action tests plus arbitrary-context controls | Evidence incomplete | +| Immutable declaration/config | Mutate original schema/config after registration/dispatch; verify frozen output | Evidence incomplete | +| Result boundaries | Public-secret collision, undeclared/duplicate/required result checks | Existing tests; current change evidence incomplete | +| Generated compatibility | Owning protobuf generation and diff, source committed before generated | Evidence incomplete | + +Instrument adequacy: demonstrate the mixed-version regression fails when its pre-handler acknowledgement check is removed. Review changed branch coverage and preserve explicit gaps. Registration global/resource paths are a required cross-product; ordinary/encrypted handler registration both enforce declaration placement. No test contacts an upstream provider. + +## Boundary resolution + +Exact additive field and acknowledgement interpretation require architecture disposition before schema or authority changes. Source inspection was explicitly required to establish whether existing APIs suffice; it preceded this plan. Preserve that exception instead of claiming implementation-blind discovery. + +## Change orders + +Append any contract or enforcement ownership change here before implementation. From 528830dba1bdddffce63a65a1f406068ef4cbab9 Mon Sep 17 00:00:00 2001 From: Squire as Brandon High <759848+highb@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:33:00 +0000 Subject: [PATCH 02/16] feat(actions): declare and acknowledge secret output content types Co-authored-by: c1-squire-dev[bot] --- .../connector-secret-types/plan.md | 4 + pkg/actions/actions.go | 12 + pkg/actions/secret_content_type.go | 122 ++++++++ pkg/actions/secret_content_type_test.go | 266 ++++++++++++++++++ proto/c1/config/v1/config.proto | 8 + 5 files changed, 412 insertions(+) create mode 100644 pkg/actions/secret_content_type.go create mode 100644 pkg/actions/secret_content_type_test.go diff --git a/docs/verification/connector-secret-types/plan.md b/docs/verification/connector-secret-types/plan.md index 9948ead31..f8097e6a1 100644 --- a/docs/verification/connector-secret-types/plan.md +++ b/docs/verification/connector-secret-types/plan.md @@ -34,3 +34,7 @@ Exact additive field and acknowledgement interpretation require architecture dis ## Change orders Append any contract or enforcement ownership change here before implementation. + +### Change order 1: resolved declaration and caller acknowledgement + +Architecture disposition selects optional config.Field.secret_content_type=8 and a pre-handler check of existing full-knowledge JWE context purpose/version/type. Check every JWE recipient, reject duplicate JSON keys, and require one secret return for non-generic types. A matching JWE recipient may coexist with Paper/age recipients. Generic declarations retain opaque contexts and existing multi-output transport. The guard establishes type agreement only, not server authentication or payload validity. Tests cover global/resource invocation, invalid placement, mixed recipients and immutable registered type metadata. Full catalog ownership remains with the consumer; SDK checks syntax only. diff --git a/pkg/actions/actions.go b/pkg/actions/actions.go index d6a8c2965..d486e7e5a 100644 --- a/pkg/actions/actions.go +++ b/pkg/actions/actions.go @@ -43,6 +43,7 @@ type actionHandlerResult struct { type registeredActionHandler struct { invoke func(context.Context, *structpb.Struct) actionHandlerResult secretReturnNames map[string]struct{} + secretReturnContentTypes map[string]string requiredSecretReturnNames []string } @@ -436,6 +437,9 @@ func (a *ActionManager) registerActionSchema(_ context.Context, name string, sch if _, ok := a.schemas[name]; ok { return fmt.Errorf("action schema %s already registered", name) } + if err := validateActionSecretContentTypes(schema); err != nil { + return err + } a.schemas[name] = schema return nil } @@ -487,6 +491,7 @@ func (a *ActionManager) RegisterWithSecrets(ctx context.Context, schema *v2.Bato return actionHandlerResult{response: response, plaintextData: plaintextData, annotations: annos, err: err} }, secretReturnNames: secretReturnNames, + secretReturnContentTypes: snapshotSecretContentTypes(schema.GetReturnTypes()), requiredSecretReturnNames: requiredSecretReturnNames, }) } @@ -568,6 +573,7 @@ func (a *ActionManager) RegisterResourceActionWithSecrets( return actionHandlerResult{response: response, plaintextData: plaintextData, annotations: annos, err: err} }, secretReturnNames: secretReturnNames, + secretReturnContentTypes: snapshotSecretContentTypes(schema.GetReturnTypes()), requiredSecretReturnNames: requiredSecretReturnNames, }) } @@ -591,6 +597,9 @@ func (a *ActionManager) registerResourceAction( return fmt.Errorf("handler cannot be nil for action %s", schema.GetName()) } + if err := validateActionSecretContentTypes(schema); err != nil { + return err + } a.mu.Lock() defer a.mu.Unlock() @@ -915,6 +924,9 @@ func (a *ActionManager) invokeRegisteredAction( if err := crypto.ValidateEncryptionConfigs(encryptionConfigs); err != nil { return "", v2.BatonActionStatus_BATON_ACTION_STATUS_FAILED, nil, nil, nil, err } + if err := validateTypedOutputContext(handler.secretReturnContentTypes, encryptionConfigs); err != nil { + return "", v2.BatonActionStatus_BATON_ACTION_STATUS_FAILED, nil, nil, nil, status.Error(codes.FailedPrecondition, err.Error()) + } var err error encryptionManager, err = crypto.NewEncryptionManager(nil, encryptionConfigs) if err != nil { diff --git a/pkg/actions/secret_content_type.go b/pkg/actions/secret_content_type.go new file mode 100644 index 000000000..7b8c086fb --- /dev/null +++ b/pkg/actions/secret_content_type.go @@ -0,0 +1,122 @@ +package actions + +import ( + "bytes" + "encoding/json" + "errors" + "io" + "regexp" + "strings" + + config "github.com/conductorone/baton-sdk/pb/c1/config/v1" + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/crypto/providers/jwe" +) + +var secretContentTypePattern = regexp.MustCompile(`^[a-z0-9]+([._-][a-z0-9]+)*$`) + +func validateActionSecretContentTypes(schema *v2.BatonActionSchema) error { + for _, field := range schema.GetArguments() { + if field.HasSecretContentType() { + return errors.New("secret content type is only valid on secret action returns") + } + } + for _, field := range schema.GetReturnTypes() { + if !field.HasSecretContentType() { + continue + } + value := field.GetSecretContentType() + if !field.GetIsSecret() || len(value) > 128 || !secretContentTypePattern.MatchString(value) { + return errors.New("secret action return content type is invalid") + } + } + return nil +} + +func snapshotSecretContentTypes(fields []*config.Field) map[string]string { + types := make(map[string]string) + for _, field := range fields { + if !field.GetIsSecret() { + continue + } + value := "generic" + if field.HasSecretContentType() { + value = field.GetSecretContentType() + } + types[field.GetName()] = value + } + return types +} + +// This checks caller type agreement before producer effects; it does not authenticate the caller or validate secret payloads. +func validateTypedOutputContext(types map[string]string, configs []*v2.EncryptionConfig) error { + contentType := "" + for _, value := range types { + if value != "generic" { + contentType = value + } + } + if contentType == "" { + return nil + } + if len(types) != 1 { + return errors.New("typed secret action output requires exactly one secret return") + } + matched := false + for _, config := range configs { + if strings.ToLower(strings.TrimSpace(config.GetProvider())) != jwe.EncryptionProvider { + continue + } + if !typedOutputContextMatches(config.GetJwkPublicKeyConfig().GetAdditionalAuthenticatedData(), contentType) { + return errors.New("typed secret action output requires a matching full-knowledge recipient context") + } + matched = true + } + if !matched { + return errors.New("typed secret action output requires a full-knowledge JWE recipient") + } + return nil +} + +func typedOutputContextMatches(encoded []byte, contentType string) bool { + if len(encoded) == 0 || len(encoded) > 16*1024 { + return false + } + decoder := json.NewDecoder(bytes.NewReader(encoded)) + token, err := decoder.Token() + if err != nil || token != json.Delim('{') { + return false + } + fields := make(map[string]json.RawMessage) + for decoder.More() { + token, err := decoder.Token() + if err != nil { + return false + } + name, ok := token.(string) + if !ok { + return false + } + if _, duplicate := fields[name]; duplicate { + return false + } + var value json.RawMessage + if err := decoder.Decode(&value); err != nil { + return false + } + fields[name] = value + } + if token, err := decoder.Token(); err != nil || token != json.Delim('}') { + return false + } + if _, err := decoder.Token(); err != io.EOF { + return false + } + var purpose, actualType string + var version uint32 + if json.Unmarshal(fields["purpose"], &purpose) != nil || json.Unmarshal(fields["version"], &version) != nil || + json.Unmarshal(fields["content_type"], &actualType) != nil { + return false + } + return purpose == "c1/fk-credential-delivery" && version == 1 && actualType == contentType +} diff --git a/pkg/actions/secret_content_type_test.go b/pkg/actions/secret_content_type_test.go new file mode 100644 index 000000000..10425bab3 --- /dev/null +++ b/pkg/actions/secret_content_type_test.go @@ -0,0 +1,266 @@ +package actions + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "strings" + "sync/atomic" + "testing" + "time" + + "filippo.io/hpke" + config "github.com/conductorone/baton-sdk/pb/c1/config/v1" + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/annotations" + "github.com/conductorone/baton-sdk/pkg/crypto/providers/jwe" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/structpb" +) + +func typedSecretSchema(contentType string) *v2.BatonActionSchema { + schema := secretActionSchema("issue_structured") + schema.GetReturnTypes()[1].SetSecretContentType(contentType) + return schema +} + +func typedContext(contentType string) []byte { + return []byte(fmt.Sprintf(`{"purpose":"c1/fk-credential-delivery","version":1,"content_type":%q}`, contentType)) +} + +func typedRecipient(t *testing.T, keyID string, aad []byte) (*v2.EncryptionConfig, hpke.PrivateKey) { + t.Helper() + private, err := hpke.MLKEM768X25519().GenerateKey() + require.NoError(t, err) + public, err := json.Marshal(map[string]string{"kty": "AKP", "alg": jwe.Algorithm, + "pub": base64.RawURLEncoding.EncodeToString(private.PublicKey().Bytes())}) + require.NoError(t, err) + return v2.EncryptionConfig_builder{Provider: jwe.EncryptionProvider, KeyId: keyID, + JwkPublicKeyConfig: v2.EncryptionConfig_JWKPublicKeyConfig_builder{PubKey: public, AdditionalAuthenticatedData: aad}.Build()}.Build(), private +} + +func registerTypedHandler(t *testing.T, manager *ActionManager, resource string, schema *v2.BatonActionSchema, calls *atomic.Int32) { + t.Helper() + handler := func(context.Context, *structpb.Struct) (*structpb.Struct, []*v2.PlaintextData, annotations.Annotations, error) { + calls.Add(1) + value, err := json.Marshal(struct { + Username string `json:"username"` + Password string `json:"password"` + }{Username: "svc-user", Password: "fixture-only"}) + if err != nil { + return nil, nil, nil, err + } + return NewReturnValues(true), []*v2.PlaintextData{v2.PlaintextData_builder{Name: "token", Bytes: value}.Build()}, nil, nil + } + if resource == "" { + require.NoError(t, RegisterWithSecrets(t.Context(), manager, schema, handler)) + } else { + require.NoError(t, manager.RegisterResourceActionWithSecrets(t.Context(), resource, schema, handler)) + } +} + +func TestSecretContentTypeSchemaRoundTrip(t *testing.T) { + for _, present := range []bool{false, true} { + schema := secretActionSchema("schema") + if present { + schema.GetReturnTypes()[1].SetSecretContentType("password_v2") + } + for _, encoding := range []string{"binary", "JSON"} { + t.Run(fmt.Sprintf("present=%t/%s", present, encoding), func(t *testing.T) { + var encoded []byte + var err error + if encoding == "binary" { + encoded, err = proto.Marshal(schema) + } else { + encoded, err = protojson.Marshal(schema) + } + require.NoError(t, err) + var decoded v2.BatonActionSchema + if encoding == "binary" { + err = proto.Unmarshal(encoded, &decoded) + } else { + err = protojson.Unmarshal(encoded, &decoded) + } + require.NoError(t, err) + require.True(t, proto.Equal(schema, &decoded)) + require.Equal(t, present, decoded.GetReturnTypes()[1].HasSecretContentType()) + }) + } + } + field := typedSecretSchema("").GetReturnTypes()[1] + require.True(t, field.HasSecretContentType()) + require.Error(t, field.ValidateAll()) +} + +func TestSecretContentTypeRegistrationRejectsInvalidDeclarations(t *testing.T) { + for _, resource := range []string{"", "account"} { + for _, tc := range []struct { + name string + mutate func(*v2.BatonActionSchema) + }{ + {"empty", func(s *v2.BatonActionSchema) { s.GetReturnTypes()[1].SetSecretContentType("") }}, + {"uppercase", func(s *v2.BatonActionSchema) { s.GetReturnTypes()[1].SetSecretContentType("Password_V2") }}, + {"whitespace", func(s *v2.BatonActionSchema) { s.GetReturnTypes()[1].SetSecretContentType(" password_v2") }}, + {"overlong", func(s *v2.BatonActionSchema) { s.GetReturnTypes()[1].SetSecretContentType(strings.Repeat("a", 129)) }}, + {"public return", func(s *v2.BatonActionSchema) { s.GetReturnTypes()[0].SetSecretContentType("password_v2") }}, + {"input", func(s *v2.BatonActionSchema) { + s.SetArguments([]*config.Field{config.Field_builder{Name: "input", IsSecret: true, SecretContentType: proto.String("password_v2")}.Build()}) + }}, + } { + t.Run(resource+"/"+tc.name, func(t *testing.T) { + manager := NewActionManager(t.Context()) + schema := typedSecretSchema("password_v2") + tc.mutate(schema) + handler := func(context.Context, *structpb.Struct) (*structpb.Struct, []*v2.PlaintextData, annotations.Annotations, error) { + t.Fatal("invalid declaration must not publish a handler") + return nil, nil, nil, nil + } + var err error + if resource == "" { + err = manager.RegisterWithSecrets(t.Context(), schema, handler) + } else { + err = manager.RegisterResourceActionWithSecrets(t.Context(), resource, schema, handler) + } + require.Error(t, err) + require.False(t, manager.HasActions()) + }) + } + for _, resourceOnly := range []bool{false, true} { + t.Run(resource+fmt.Sprintf("/ordinary registration=%t", resourceOnly), func(t *testing.T) { + manager := NewActionManager(t.Context()) + schema := secretActionSchema("public") + schema.SetReturnTypes(schema.GetReturnTypes()[:1]) + schema.GetReturnTypes()[0].SetSecretContentType("password_v2") + handler := func(context.Context, *structpb.Struct) (*structpb.Struct, annotations.Annotations, error) { + return nil, nil, nil + } + var err error + if resourceOnly { + err = manager.RegisterResourceAction(t.Context(), "account", schema, handler) + } else { + err = manager.Register(t.Context(), schema, handler) + } + require.Error(t, err) + require.False(t, manager.HasActions()) + }) + } + } +} + +func TestTypedSecretOutputRefusesUnacknowledgedCallerBeforeHandler(t *testing.T) { + for _, resource := range []string{"", "account"} { + for _, tc := range []struct{ name, context string }{ + {"absent", ""}, {"opaque", "opaque"}, {"generic", string(typedContext("generic"))}, + {"wrong type", string(typedContext("api_key_v2"))}, + {"wrong purpose", `{"purpose":"other","version":1,"content_type":"password_v2"}`}, + {"wrong version", `{"purpose":"c1/fk-credential-delivery","version":2,"content_type":"password_v2"}`}, + {"string version", `{"purpose":"c1/fk-credential-delivery","version":"1","content_type":"password_v2"}`}, + {"duplicate type", `{"purpose":"c1/fk-credential-delivery","version":1,"content_type":"generic","content_type":"password_v2"}`}, + {"escaped duplicate", `{"purpose":"c1/fk-credential-delivery","version":1,"content_type":"password_v2","content\u005ftype":"password_v2"}`}, + {"malformed", `{"purpose":`}, {"array", `[]`}, {"trailing", string(typedContext("password_v2")) + `{}`}, + } { + t.Run(resource+"/"+tc.name, func(t *testing.T) { + manager := NewActionManager(t.Context()) + var calls atomic.Int32 + registerTypedHandler(t, manager, resource, typedSecretSchema("password_v2"), &calls) + config, _ := typedRecipient(t, "recipient", []byte(tc.context)) + id, _, _, _, _, err := manager.InvokeActionWithWaitAndEncryption(t.Context(), "issue_structured", resource, nil, time.Second, []*v2.EncryptionConfig{config}) + require.Error(t, err) + require.Empty(t, id) + require.Zero(t, calls.Load()) + require.Empty(t, manager.actions) + }) + } + for _, scenario := range []string{"age only", "conflicting JWE", "multiple secrets"} { + t.Run(resource+"/"+scenario, func(t *testing.T) { + manager := NewActionManager(t.Context()) + var calls atomic.Int32 + schema := typedSecretSchema("password_v2") + if scenario == "multiple secrets" { + schema.SetReturnTypes(append(schema.GetReturnTypes(), config.Field_builder{Name: "other", IsSecret: true}.Build())) + } + registerTypedHandler(t, manager, resource, schema, &calls) + matching, _ := typedRecipient(t, "matching", typedContext("password_v2")) + configs := []*v2.EncryptionConfig{matching} + if scenario == "age only" { + age, _ := ageEncryptionConfig(t) + configs = []*v2.EncryptionConfig{age} + } else if scenario == "conflicting JWE" { + conflicting, _ := typedRecipient(t, "conflicting", typedContext("api_key_v2")) + configs = append(configs, conflicting) + } + id, _, _, _, _, err := manager.InvokeActionWithWaitAndEncryption(t.Context(), "issue_structured", resource, nil, time.Second, configs) + require.Error(t, err) + require.Empty(t, id) + require.Zero(t, calls.Load()) + require.Empty(t, manager.actions) + }) + } + } +} + +func TestTypedSecretOutputEncryptsExactHelperBytesWithFrozenDeclaration(t *testing.T) { + for _, resource := range []string{"", "account"} { + t.Run(resource, func(t *testing.T) { + manager := NewActionManager(t.Context()) + var calls atomic.Int32 + schema := typedSecretSchema("password_v2") + registerTypedHandler(t, manager, resource, schema, &calls) + schema.GetReturnTypes()[1].SetSecretContentType("api_key_v2") + config, private := typedRecipient(t, "recipient", typedContext("password_v2")) + age, identity := ageEncryptionConfig(t) + id, result, public, encrypted, _, err := manager.InvokeActionWithWaitAndEncryption(t.Context(), "issue_structured", resource, nil, time.Second, []*v2.EncryptionConfig{config, age}) + require.NoError(t, err) + require.NotEmpty(t, id) + require.Equal(t, v2.BatonActionStatus_BATON_ACTION_STATUS_COMPLETE, result) + require.Equal(t, int32(1), calls.Load()) + require.NotContains(t, public.GetFields(), "token") + require.Len(t, encrypted, 2) + var jweResult, ageResult *v2.EncryptedData + for _, entry := range encrypted { + if entry.GetProvider() == jwe.EncryptionProvider { + jweResult = entry + } else { + ageResult = entry + } + } + require.NotNil(t, jweResult) + require.NotNil(t, ageResult) + var envelope jwe.FlattenedJWE + require.NoError(t, json.Unmarshal(jweResult.GetEncryptedBytes(), &envelope)) + aad, err := base64.RawURLEncoding.Strict().DecodeString(envelope.AAD) + require.NoError(t, err) + require.Equal(t, typedContext("password_v2"), aad) + enc, err := base64.RawURLEncoding.Strict().DecodeString(envelope.EncryptedKey) + require.NoError(t, err) + ciphertext, err := base64.RawURLEncoding.Strict().DecodeString(envelope.Ciphertext) + require.NoError(t, err) + recipient, err := hpke.NewRecipient(enc, private, hpke.HKDFSHA256(), hpke.ChaCha20Poly1305(), nil) + require.NoError(t, err) + opened, err := recipient.Open([]byte(envelope.Protected+"."+envelope.AAD), ciphertext) + require.NoError(t, err) + require.Equal(t, []byte(`{"username":"svc-user","password":"fixture-only"}`), opened) + require.Equal(t, opened, decryptAgeActionResult(t, ageResult, identity)) + }) + } +} + +func TestGenericSecretOutputKeepsOpaqueContexts(t *testing.T) { + for _, explicit := range []bool{false, true} { + manager := NewActionManager(t.Context()) + var calls atomic.Int32 + schema := secretActionSchema("issue_structured") + if explicit { + schema.GetReturnTypes()[1].SetSecretContentType("generic") + } + registerTypedHandler(t, manager, "", schema, &calls) + config, _ := typedRecipient(t, "recipient", []byte("opaque legacy context")) + _, result, _, _, _, err := manager.InvokeActionWithWaitAndEncryption(t.Context(), "issue_structured", "", nil, time.Second, []*v2.EncryptionConfig{config}) + require.NoError(t, err) + require.Equal(t, v2.BatonActionStatus_BATON_ACTION_STATUS_COMPLETE, result) + require.Equal(t, int32(1), calls.Load()) + } +} diff --git a/proto/c1/config/v1/config.proto b/proto/c1/config/v1/config.proto index 29f3c4ca6..bce369676 100644 --- a/proto/c1/config/v1/config.proto +++ b/proto/c1/config/v1/config.proto @@ -4,6 +4,7 @@ package c1.config.v1; import "c1/config/v1/rules.proto"; import "google/protobuf/any.proto"; +import "validate/validate.proto"; option go_package = "github.com/conductorone/baton-sdk/pb/c1/config/v1"; @@ -58,6 +59,13 @@ message Field { // through ActionHandlerWithSecrets; it is omitted from the public response // and returned as EncryptedData. bool is_secret = 7; + // Secret action return content type. Absent means generic; not valid on + // configuration fields, action arguments, or public returns. + optional string secret_content_type = 8 [(validate.rules).string = { + min_len: 1 + max_len: 128 + pattern: "^[a-z0-9]+([._-][a-z0-9]+)*$" + }]; oneof field { StringField string_field = 100; From 9a550250d4d0a0ec5b889e3212e28aec4969e29a Mon Sep 17 00:00:00 2001 From: Squire as Brandon High <759848+highb@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:34:21 +0000 Subject: [PATCH 03/16] chore(proto): generate secret content type declaration Co-authored-by: c1-squire-dev[bot] --- pb/c1/config/v1/config.pb.go | 40 ++++++++++++-- pb/c1/config/v1/config.pb.validate.go | 28 ++++++++++ pb/c1/config/v1/config_protoopaque.pb.go | 70 +++++++++++++++++++----- 3 files changed, 119 insertions(+), 19 deletions(-) diff --git a/pb/c1/config/v1/config.pb.go b/pb/c1/config/v1/config.pb.go index bfadc34e3..07950ec3a 100644 --- a/pb/c1/config/v1/config.pb.go +++ b/pb/c1/config/v1/config.pb.go @@ -9,6 +9,7 @@ package v1 import ( + _ "github.com/envoyproxy/protoc-gen-validate/validate" protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" anypb "google.golang.org/protobuf/types/known/anypb" @@ -564,6 +565,9 @@ type Field struct { // through ActionHandlerWithSecrets; it is omitted from the public response // and returned as EncryptedData. IsSecret bool `protobuf:"varint,7,opt,name=is_secret,json=isSecret,proto3" json:"is_secret,omitempty"` + // Secret action return content type. Absent means generic; not valid on + // configuration fields, action arguments, or public returns. + SecretContentType *string `protobuf:"bytes,8,opt,name=secret_content_type,json=secretContentType,proto3,oneof" json:"secret_content_type,omitempty"` // Types that are valid to be assigned to Field: // // *Field_StringField @@ -656,6 +660,13 @@ func (x *Field) GetIsSecret() bool { return false } +func (x *Field) GetSecretContentType() string { + if x != nil && x.SecretContentType != nil { + return *x.SecretContentType + } + return "" +} + func (x *Field) GetField() isField_Field { if x != nil { return x.Field @@ -790,6 +801,10 @@ func (x *Field) SetIsSecret(v bool) { x.IsSecret = v } +func (x *Field) SetSecretContentType(v string) { + x.SecretContentType = &v +} + func (x *Field) SetStringField(v *StringField) { if v == nil { x.Field = nil @@ -878,6 +893,13 @@ func (x *Field) SetGrantSliceField(v *GrantSliceField) { x.Field = &Field_GrantSliceField{v} } +func (x *Field) HasSecretContentType() bool { + if x == nil { + return false + } + return x.SecretContentType != nil +} + func (x *Field) HasField() bool { if x == nil { return false @@ -973,6 +995,10 @@ func (x *Field) HasGrantSliceField() bool { return ok } +func (x *Field) ClearSecretContentType() { + x.SecretContentType = nil +} + func (x *Field) ClearField() { x.Field = nil } @@ -1102,6 +1128,9 @@ type Field_builder struct { // through ActionHandlerWithSecrets; it is omitted from the public response // and returned as EncryptedData. IsSecret bool + // Secret action return content type. Absent means generic; not valid on + // configuration fields, action arguments, or public returns. + SecretContentType *string // Fields of oneof Field: StringField *StringField IntField *IntField @@ -1129,6 +1158,7 @@ func (b0 Field_builder) Build() *Field { x.IsRequired = b.IsRequired x.IsOps = b.IsOps x.IsSecret = b.IsSecret + x.SecretContentType = b.SecretContentType if b.StringField != nil { x.Field = &Field_StringField{b.StringField} } @@ -2805,7 +2835,7 @@ var File_c1_config_v1_config_proto protoreflect.FileDescriptor const file_c1_config_v1_config_proto_rawDesc = "" + "\n" + - "\x19c1/config/v1/config.proto\x12\fc1.config.v1\x1a\x18c1/config/v1/rules.proto\x1a\x19google/protobuf/any.proto\"\xd0\x03\n" + + "\x19c1/config/v1/config.proto\x12\fc1.config.v1\x1a\x18c1/config/v1/rules.proto\x1a\x19google/protobuf/any.proto\x1a\x17validate/validate.proto\"\xd0\x03\n" + "\rConfiguration\x12+\n" + "\x06fields\x18\x01 \x03(\v2\x13.c1.config.v1.FieldR\x06fields\x12:\n" + "\vconstraints\x18\x02 \x03(\v2\x18.c1.config.v1.ConstraintR\vconstraints\x12!\n" + @@ -2834,7 +2864,7 @@ const file_c1_config_v1_config_proto_rawDesc = "" + "\fdisplay_name\x18\x02 \x01(\tR\vdisplayName\x12\x1b\n" + "\thelp_text\x18\x03 \x01(\tR\bhelpText\x12\x16\n" + "\x06fields\x18\x04 \x03(\tR\x06fields\x12\x18\n" + - "\adefault\x18\x05 \x01(\bR\adefault\"\x9d\b\n" + + "\adefault\x18\x05 \x01(\bR\adefault\"\x94\t\n" + "\x05Field\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12!\n" + "\fdisplay_name\x18\x02 \x01(\tR\vdisplayName\x12 \n" + @@ -2843,7 +2873,8 @@ const file_c1_config_v1_config_proto_rawDesc = "" + "\vis_required\x18\x05 \x01(\bR\n" + "isRequired\x12\x15\n" + "\x06is_ops\x18\x06 \x01(\bR\x05isOps\x12\x1b\n" + - "\tis_secret\x18\a \x01(\bR\bisSecret\x12>\n" + + "\tis_secret\x18\a \x01(\bR\bisSecret\x12]\n" + + "\x13secret_content_type\x18\b \x01(\tB(\xfaB%r#\x10\x01\x18\x80\x012\x1c^[a-z0-9]+([._-][a-z0-9]+)*$H\x01R\x11secretContentType\x88\x01\x01\x12>\n" + "\fstring_field\x18d \x01(\v2\x19.c1.config.v1.StringFieldH\x00R\vstringField\x125\n" + "\tint_field\x18e \x01(\v2\x16.c1.config.v1.IntFieldH\x00R\bintField\x128\n" + "\n" + @@ -2856,7 +2887,8 @@ const file_c1_config_v1_config_proto_rawDesc = "" + "\x14resource_slice_field\x18l \x01(\v2 .c1.config.v1.ResourceSliceFieldH\x00R\x12resourceSliceField\x12]\n" + "\x17entitlement_slice_field\x18m \x01(\v2#.c1.config.v1.EntitlementSliceFieldH\x00R\x15entitlementSliceField\x12K\n" + "\x11grant_slice_field\x18n \x01(\v2\x1d.c1.config.v1.GrantSliceFieldH\x00R\x0fgrantSliceFieldB\a\n" + - "\x05field\"\x8a\x02\n" + + "\x05fieldB\x16\n" + + "\x14_secret_content_type\"\x8a\x02\n" + "\bResource\x129\n" + "\vresource_id\x18\x01 \x01(\v2\x18.c1.config.v1.ResourceIdR\n" + "resourceId\x12F\n" + diff --git a/pb/c1/config/v1/config.pb.validate.go b/pb/c1/config/v1/config.pb.validate.go index 6cacb6839..9eb6a51db 100644 --- a/pb/c1/config/v1/config.pb.validate.go +++ b/pb/c1/config/v1/config.pb.validate.go @@ -956,6 +956,32 @@ func (m *Field) validate(all bool) error { _ = v // ensures v is used } + if m.SecretContentType != nil { + + if l := utf8.RuneCountInString(m.GetSecretContentType()); l < 1 || l > 128 { + err := FieldValidationError{ + field: "SecretContentType", + reason: "value length must be between 1 and 128 runes, inclusive", + } + if !all { + return err + } + errors = append(errors, err) + } + + if !_Field_SecretContentType_Pattern.MatchString(m.GetSecretContentType()) { + err := FieldValidationError{ + field: "SecretContentType", + reason: "value does not match regex pattern \"^[a-z0-9]+([._-][a-z0-9]+)*$\"", + } + if !all { + return err + } + errors = append(errors, err) + } + + } + if len(errors) > 0 { return FieldMultiError(errors) } @@ -1033,6 +1059,8 @@ var _ interface { ErrorName() string } = FieldValidationError{} +var _Field_SecretContentType_Pattern = regexp.MustCompile("^[a-z0-9]+([._-][a-z0-9]+)*$") + // Validate checks the field values on Resource with the rules defined in the // proto definition for this message. If any rules are violated, the first // error encountered is returned, or nil if there are no violations. diff --git a/pb/c1/config/v1/config_protoopaque.pb.go b/pb/c1/config/v1/config_protoopaque.pb.go index 96ead9af2..3ba1edf5d 100644 --- a/pb/c1/config/v1/config_protoopaque.pb.go +++ b/pb/c1/config/v1/config_protoopaque.pb.go @@ -9,6 +9,7 @@ package v1 import ( + _ "github.com/envoyproxy/protoc-gen-validate/validate" protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" anypb "google.golang.org/protobuf/types/known/anypb" @@ -558,17 +559,20 @@ func (b0 FieldGroup_builder) Build() *FieldGroup { } type Field struct { - state protoimpl.MessageState `protogen:"opaque.v1"` - xxx_hidden_Name string `protobuf:"bytes,1,opt,name=name,proto3"` - xxx_hidden_DisplayName string `protobuf:"bytes,2,opt,name=display_name,json=displayName,proto3"` - xxx_hidden_Description string `protobuf:"bytes,3,opt,name=description,proto3"` - xxx_hidden_Placeholder string `protobuf:"bytes,4,opt,name=placeholder,proto3"` - xxx_hidden_IsRequired bool `protobuf:"varint,5,opt,name=is_required,json=isRequired,proto3"` - xxx_hidden_IsOps bool `protobuf:"varint,6,opt,name=is_ops,json=isOps,proto3"` - xxx_hidden_IsSecret bool `protobuf:"varint,7,opt,name=is_secret,json=isSecret,proto3"` - xxx_hidden_Field isField_Field `protobuf_oneof:"field"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"opaque.v1"` + xxx_hidden_Name string `protobuf:"bytes,1,opt,name=name,proto3"` + xxx_hidden_DisplayName string `protobuf:"bytes,2,opt,name=display_name,json=displayName,proto3"` + xxx_hidden_Description string `protobuf:"bytes,3,opt,name=description,proto3"` + xxx_hidden_Placeholder string `protobuf:"bytes,4,opt,name=placeholder,proto3"` + xxx_hidden_IsRequired bool `protobuf:"varint,5,opt,name=is_required,json=isRequired,proto3"` + xxx_hidden_IsOps bool `protobuf:"varint,6,opt,name=is_ops,json=isOps,proto3"` + xxx_hidden_IsSecret bool `protobuf:"varint,7,opt,name=is_secret,json=isSecret,proto3"` + xxx_hidden_SecretContentType *string `protobuf:"bytes,8,opt,name=secret_content_type,json=secretContentType,proto3,oneof"` + xxx_hidden_Field isField_Field `protobuf_oneof:"field"` + XXX_raceDetectHookData protoimpl.RaceDetectHookData + XXX_presence [1]uint32 + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *Field) Reset() { @@ -645,6 +649,16 @@ func (x *Field) GetIsSecret() bool { return false } +func (x *Field) GetSecretContentType() string { + if x != nil { + if x.xxx_hidden_SecretContentType != nil { + return *x.xxx_hidden_SecretContentType + } + return "" + } + return "" +} + func (x *Field) GetStringField() *StringField { if x != nil { if x, ok := x.xxx_hidden_Field.(*field_StringField); ok { @@ -772,6 +786,11 @@ func (x *Field) SetIsSecret(v bool) { x.xxx_hidden_IsSecret = v } +func (x *Field) SetSecretContentType(v string) { + x.xxx_hidden_SecretContentType = &v + protoimpl.X.SetPresent(&(x.XXX_presence[0]), 7, 9) +} + func (x *Field) SetStringField(v *StringField) { if v == nil { x.xxx_hidden_Field = nil @@ -860,6 +879,13 @@ func (x *Field) SetGrantSliceField(v *GrantSliceField) { x.xxx_hidden_Field = &field_GrantSliceField{v} } +func (x *Field) HasSecretContentType() bool { + if x == nil { + return false + } + return protoimpl.X.Present(&(x.XXX_presence[0]), 7) +} + func (x *Field) HasField() bool { if x == nil { return false @@ -955,6 +981,11 @@ func (x *Field) HasGrantSliceField() bool { return ok } +func (x *Field) ClearSecretContentType() { + protoimpl.X.ClearPresent(&(x.XXX_presence[0]), 7) + x.xxx_hidden_SecretContentType = nil +} + func (x *Field) ClearField() { x.xxx_hidden_Field = nil } @@ -1084,6 +1115,9 @@ type Field_builder struct { // through ActionHandlerWithSecrets; it is omitted from the public response // and returned as EncryptedData. IsSecret bool + // Secret action return content type. Absent means generic; not valid on + // configuration fields, action arguments, or public returns. + SecretContentType *string // Fields of oneof xxx_hidden_Field: StringField *StringField IntField *IntField @@ -1111,6 +1145,10 @@ func (b0 Field_builder) Build() *Field { x.xxx_hidden_IsRequired = b.IsRequired x.xxx_hidden_IsOps = b.IsOps x.xxx_hidden_IsSecret = b.IsSecret + if b.SecretContentType != nil { + protoimpl.X.SetPresentNonAtomic(&(x.XXX_presence[0]), 7, 9) + x.xxx_hidden_SecretContentType = b.SecretContentType + } if b.StringField != nil { x.xxx_hidden_Field = &field_StringField{b.StringField} } @@ -2797,7 +2835,7 @@ var File_c1_config_v1_config_proto protoreflect.FileDescriptor const file_c1_config_v1_config_proto_rawDesc = "" + "\n" + - "\x19c1/config/v1/config.proto\x12\fc1.config.v1\x1a\x18c1/config/v1/rules.proto\x1a\x19google/protobuf/any.proto\"\xd0\x03\n" + + "\x19c1/config/v1/config.proto\x12\fc1.config.v1\x1a\x18c1/config/v1/rules.proto\x1a\x19google/protobuf/any.proto\x1a\x17validate/validate.proto\"\xd0\x03\n" + "\rConfiguration\x12+\n" + "\x06fields\x18\x01 \x03(\v2\x13.c1.config.v1.FieldR\x06fields\x12:\n" + "\vconstraints\x18\x02 \x03(\v2\x18.c1.config.v1.ConstraintR\vconstraints\x12!\n" + @@ -2826,7 +2864,7 @@ const file_c1_config_v1_config_proto_rawDesc = "" + "\fdisplay_name\x18\x02 \x01(\tR\vdisplayName\x12\x1b\n" + "\thelp_text\x18\x03 \x01(\tR\bhelpText\x12\x16\n" + "\x06fields\x18\x04 \x03(\tR\x06fields\x12\x18\n" + - "\adefault\x18\x05 \x01(\bR\adefault\"\x9d\b\n" + + "\adefault\x18\x05 \x01(\bR\adefault\"\x94\t\n" + "\x05Field\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12!\n" + "\fdisplay_name\x18\x02 \x01(\tR\vdisplayName\x12 \n" + @@ -2835,7 +2873,8 @@ const file_c1_config_v1_config_proto_rawDesc = "" + "\vis_required\x18\x05 \x01(\bR\n" + "isRequired\x12\x15\n" + "\x06is_ops\x18\x06 \x01(\bR\x05isOps\x12\x1b\n" + - "\tis_secret\x18\a \x01(\bR\bisSecret\x12>\n" + + "\tis_secret\x18\a \x01(\bR\bisSecret\x12]\n" + + "\x13secret_content_type\x18\b \x01(\tB(\xfaB%r#\x10\x01\x18\x80\x012\x1c^[a-z0-9]+([._-][a-z0-9]+)*$H\x01R\x11secretContentType\x88\x01\x01\x12>\n" + "\fstring_field\x18d \x01(\v2\x19.c1.config.v1.StringFieldH\x00R\vstringField\x125\n" + "\tint_field\x18e \x01(\v2\x16.c1.config.v1.IntFieldH\x00R\bintField\x128\n" + "\n" + @@ -2848,7 +2887,8 @@ const file_c1_config_v1_config_proto_rawDesc = "" + "\x14resource_slice_field\x18l \x01(\v2 .c1.config.v1.ResourceSliceFieldH\x00R\x12resourceSliceField\x12]\n" + "\x17entitlement_slice_field\x18m \x01(\v2#.c1.config.v1.EntitlementSliceFieldH\x00R\x15entitlementSliceField\x12K\n" + "\x11grant_slice_field\x18n \x01(\v2\x1d.c1.config.v1.GrantSliceFieldH\x00R\x0fgrantSliceFieldB\a\n" + - "\x05field\"\x8a\x02\n" + + "\x05fieldB\x16\n" + + "\x14_secret_content_type\"\x8a\x02\n" + "\bResource\x129\n" + "\vresource_id\x18\x01 \x01(\v2\x18.c1.config.v1.ResourceIdR\n" + "resourceId\x12F\n" + From a680c622cb88a262dd52400361d594ee7313b3bc Mon Sep 17 00:00:00 2001 From: Squire as Brandon High <759848+highb@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:39:53 +0000 Subject: [PATCH 04/16] test(actions): preserve singleton JWE output transport Co-authored-by: c1-squire-dev[bot] --- .../connector-secret-types/evidence.md | 16 ++++++++++++- .../connector-secret-types/plan.md | 4 ++++ pkg/actions/secret_content_type_test.go | 23 +++++++------------ 3 files changed, 27 insertions(+), 16 deletions(-) diff --git a/docs/verification/connector-secret-types/evidence.md b/docs/verification/connector-secret-types/evidence.md index f94a4e6e2..85761f24c 100644 --- a/docs/verification/connector-secret-types/evidence.md +++ b/docs/verification/connector-secret-types/evidence.md @@ -1,3 +1,17 @@ # Evidence ledger -Base: released v0.42.3, commit 2e045d482d592a9462b5f87c70a6113fe87ec54a. Schema inspection found no per-secret content type declaration on config.Field or BatonActionSchema. No runtime change is implemented in this plan commit. Architecture disposition and execution receipts remain pending. +Base: released v0.42.3, commit 2e045d482d592a9462b5f87c70a6113fe87ec54a. Source declaration/guard: 528830db; generated protobuf: 9a550250. No release is claimed. + +| Criterion | Executable artifact and assertion | Status | +| --- | --- | --- | +| Declaration presence and schema round-trip | TestSecretContentTypeSchemaRoundTrip, binary/JSON equality and HasSecretContentType absent/present; explicit empty ValidateAll refusal | Verified to stated coverage | +| Illegal declarations | TestSecretContentTypeRegistrationRejectsInvalidDeclarations, global/resource and ordinary/encrypted registration; no published action on invalid input/public/empty/grammar/length | Verified to stated coverage | +| Pre-handler downgrade prevention | TestTypedSecretOutputRefusesUnacknowledgedCallerBeforeHandler, global/resource; missing/opaque/generic/wrong type/purpose/version/ambiguous/invalid JSON plus unsupported recipients/cardinality; empty action ID, handler counter zero, no allocation | Verified to stated coverage | +| Exact structured bytes and immutable registration type | TestTypedSecretOutputEncryptsExactHelperBytesWithFrozenDeclaration, actual manager/helper/JWE; mutate original schema after registration; independently open X-Wing HPKE bytes and compare canonical username/password JSON | Verified to stated coverage | +| Legacy generic compatibility | TestGenericSecretOutputKeepsOpaqueContexts plus existing ActionHandlerWithSecrets tests; absent/explicit generic and old age multi-output/recipient paths | Verified to stated coverage | +| Instrument adequacy | Removed new guard temporarily, ran account/generic regression: failed because expected error was nil; restored source byte-for-byte | Verified bounded mutation | +| Source generation | make protofmt; make protogen exit0. Only config.pb.go, config.pb.validate.go, config_protoopaque.pb.go changed; generated separately | Verified | +| Full package and race | Execution pending final package run | Evidence incomplete | +| Cross-repository integration | C1 consumer adoption, real member/Store/reveal/canonical codec/renderer | Deferred to consumer integration stage; not an SDK-only claim | + +Initial actual invocation test failed on JWE+Paper due to the pre-existing singleton-JWE validator. That failed assumption is preserved in the change-order history; tests now assert the released contract rather than expanding native/shared validation. The caller context guard establishes agreement, not server authentication or payload admission. No provider operations were executed. diff --git a/docs/verification/connector-secret-types/plan.md b/docs/verification/connector-secret-types/plan.md index f8097e6a1..2f76931c5 100644 --- a/docs/verification/connector-secret-types/plan.md +++ b/docs/verification/connector-secret-types/plan.md @@ -38,3 +38,7 @@ Append any contract or enforcement ownership change here before implementation. ### Change order 1: resolved declaration and caller acknowledgement Architecture disposition selects optional config.Field.secret_content_type=8 and a pre-handler check of existing full-knowledge JWE context purpose/version/type. Check every JWE recipient, reject duplicate JSON keys, and require one secret return for non-generic types. A matching JWE recipient may coexist with Paper/age recipients. Generic declarations retain opaque contexts and existing multi-output transport. The guard establishes type agreement only, not server authentication or payload validity. Tests cover global/resource invocation, invalid placement, mixed recipients and immutable registered type metadata. Full catalog ownership remains with the consumer; SDK checks syntax only. + +### Change order 2: preserve existing singleton-JWE transport + +Executed invocation evidence showed that released ValidateEncryptionConfigs rejects any JWE recipient in a configuration set with length other than one. The earlier JWE+Paper positive assumption was counterfactual. Architecture correction preserves that existing restriction; JWE+age and multi-JWE are rejection controls before action allocation/handler effects. The positive typed path uses exactly one JWE recipient and one secret output. Generic age-only multi-recipient/multi-output behavior is unchanged. NewEncryptionManager does not itself enforce this configuration validation; the common ActionManager invocation boundary does, before construction and handler dispatch. No shared/native crypto API changes are introduced. diff --git a/pkg/actions/secret_content_type_test.go b/pkg/actions/secret_content_type_test.go index 10425bab3..e4b2d0c0d 100644 --- a/pkg/actions/secret_content_type_test.go +++ b/pkg/actions/secret_content_type_test.go @@ -174,7 +174,7 @@ func TestTypedSecretOutputRefusesUnacknowledgedCallerBeforeHandler(t *testing.T) require.Empty(t, manager.actions) }) } - for _, scenario := range []string{"age only", "conflicting JWE", "multiple secrets"} { + for _, scenario := range []string{"age only", "conflicting JWE", "JWE plus age", "multiple secrets"} { t.Run(resource+"/"+scenario, func(t *testing.T) { manager := NewActionManager(t.Context()) var calls atomic.Int32 @@ -188,6 +188,9 @@ func TestTypedSecretOutputRefusesUnacknowledgedCallerBeforeHandler(t *testing.T) if scenario == "age only" { age, _ := ageEncryptionConfig(t) configs = []*v2.EncryptionConfig{age} + } else if scenario == "JWE plus age" { + age, _ := ageEncryptionConfig(t) + configs = append(configs, age) } else if scenario == "conflicting JWE" { conflicting, _ := typedRecipient(t, "conflicting", typedContext("api_key_v2")) configs = append(configs, conflicting) @@ -211,24 +214,15 @@ func TestTypedSecretOutputEncryptsExactHelperBytesWithFrozenDeclaration(t *testi registerTypedHandler(t, manager, resource, schema, &calls) schema.GetReturnTypes()[1].SetSecretContentType("api_key_v2") config, private := typedRecipient(t, "recipient", typedContext("password_v2")) - age, identity := ageEncryptionConfig(t) - id, result, public, encrypted, _, err := manager.InvokeActionWithWaitAndEncryption(t.Context(), "issue_structured", resource, nil, time.Second, []*v2.EncryptionConfig{config, age}) + id, result, public, encrypted, _, err := manager.InvokeActionWithWaitAndEncryption(t.Context(), "issue_structured", resource, nil, time.Second, []*v2.EncryptionConfig{config}) require.NoError(t, err) require.NotEmpty(t, id) require.Equal(t, v2.BatonActionStatus_BATON_ACTION_STATUS_COMPLETE, result) require.Equal(t, int32(1), calls.Load()) require.NotContains(t, public.GetFields(), "token") - require.Len(t, encrypted, 2) - var jweResult, ageResult *v2.EncryptedData - for _, entry := range encrypted { - if entry.GetProvider() == jwe.EncryptionProvider { - jweResult = entry - } else { - ageResult = entry - } - } - require.NotNil(t, jweResult) - require.NotNil(t, ageResult) + require.Len(t, encrypted, 1) + jweResult := encrypted[0] + require.Equal(t, jwe.EncryptionProvider, jweResult.GetProvider()) var envelope jwe.FlattenedJWE require.NoError(t, json.Unmarshal(jweResult.GetEncryptedBytes(), &envelope)) aad, err := base64.RawURLEncoding.Strict().DecodeString(envelope.AAD) @@ -243,7 +237,6 @@ func TestTypedSecretOutputEncryptsExactHelperBytesWithFrozenDeclaration(t *testi opened, err := recipient.Open([]byte(envelope.Protected+"."+envelope.AAD), ciphertext) require.NoError(t, err) require.Equal(t, []byte(`{"username":"svc-user","password":"fixture-only"}`), opened) - require.Equal(t, opened, decryptAgeActionResult(t, ageResult, identity)) }) } } From da275f138a7e5bc6bce46e786e147d3bfd6f4643 Mon Sep 17 00:00:00 2001 From: Squire as Brandon High <759848+highb@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:40:42 +0000 Subject: [PATCH 05/16] docs(actions): record typed output regression evidence Co-authored-by: c1-squire-dev[bot] --- docs/verification/connector-secret-types/evidence.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/verification/connector-secret-types/evidence.md b/docs/verification/connector-secret-types/evidence.md index 85761f24c..17c13535d 100644 --- a/docs/verification/connector-secret-types/evidence.md +++ b/docs/verification/connector-secret-types/evidence.md @@ -11,7 +11,7 @@ Base: released v0.42.3, commit 2e045d482d592a9462b5f87c70a6113fe87ec54a. Source | Legacy generic compatibility | TestGenericSecretOutputKeepsOpaqueContexts plus existing ActionHandlerWithSecrets tests; absent/explicit generic and old age multi-output/recipient paths | Verified to stated coverage | | Instrument adequacy | Removed new guard temporarily, ran account/generic regression: failed because expected error was nil; restored source byte-for-byte | Verified bounded mutation | | Source generation | make protofmt; make protogen exit0. Only config.pb.go, config.pb.validate.go, config_protoopaque.pb.go changed; generated separately | Verified | -| Full package and race | Execution pending final package run | Evidence incomplete | +| Full package and race | go test -race -v ./pkg/actions -count=1; all named package tests ran/pass, no skips; 11.188s package execution | Verified to stated coverage | | Cross-repository integration | C1 consumer adoption, real member/Store/reveal/canonical codec/renderer | Deferred to consumer integration stage; not an SDK-only claim | Initial actual invocation test failed on JWE+Paper due to the pre-existing singleton-JWE validator. That failed assumption is preserved in the change-order history; tests now assert the released contract rather than expanding native/shared validation. The caller context guard establishes agreement, not server authentication or payload admission. No provider operations were executed. From 4483426d56bba5edbd760fc716f65c946ecc4980 Mon Sep 17 00:00:00 2001 From: Squire as Brandon High <759848+highb@users.noreply.github.com> Date: Fri, 23 Oct 2026 00:00:37 +0000 Subject: [PATCH 06/16] fix(actions): snapshot advertised secret output schemas Co-authored-by: c1-squire-dev[bot] --- .../connector-secret-types/plan.md | 4 ++ pkg/actions/actions.go | 12 ++--- pkg/actions/secret_content_type_test.go | 52 +++++++++++++++++++ 3 files changed, 62 insertions(+), 6 deletions(-) diff --git a/docs/verification/connector-secret-types/plan.md b/docs/verification/connector-secret-types/plan.md index 2f76931c5..35a509c2e 100644 --- a/docs/verification/connector-secret-types/plan.md +++ b/docs/verification/connector-secret-types/plan.md @@ -42,3 +42,7 @@ Architecture disposition selects optional config.Field.secret_content_type=8 and ### Change order 2: preserve existing singleton-JWE transport Executed invocation evidence showed that released ValidateEncryptionConfigs rejects any JWE recipient in a configuration set with length other than one. The earlier JWE+Paper positive assumption was counterfactual. Architecture correction preserves that existing restriction; JWE+age and multi-JWE are rejection controls before action allocation/handler effects. The positive typed path uses exactly one JWE recipient and one secret output. Generic age-only multi-recipient/multi-output behavior is unchanged. NewEncryptionManager does not itself enforce this configuration validation; the common ActionManager invocation boundary does, before construction and handler dispatch. No shared/native crypto API changes are introduced. + +### Change order 3: immutable advertised schema + +A source check identified a correction required by the original immutable-registration criterion: the guard copied type strings, but registration and schema getters retained shared mutable schema pointers. The new six-case global/resource regression failed when caller mutation changed the advertised absent/generic/password declaration into api_key_v2 while the registered handler retained its original type. Publication and read APIs now clone schemas. Mutations of the original, listed and fetched schemas cannot alter advertised/enforced agreement. This adds linear copying in action schema size on registration/discovery, outside declared artifact-open/grant-scale hot paths; no provider, store or credential lifecycle changes. diff --git a/pkg/actions/actions.go b/pkg/actions/actions.go index d486e7e5a..bd55c466b 100644 --- a/pkg/actions/actions.go +++ b/pkg/actions/actions.go @@ -440,7 +440,7 @@ func (a *ActionManager) registerActionSchema(_ context.Context, name string, sch if err := validateActionSecretContentTypes(schema); err != nil { return err } - a.schemas[name] = schema + a.schemas[name] = proto.Clone(schema).(*v2.BatonActionSchema) return nil } @@ -640,7 +640,7 @@ func (a *ActionManager) registerResourceAction( } } - a.resourceSchemas[resourceTypeID][actionName] = schema + a.resourceSchemas[resourceTypeID][actionName] = proto.Clone(schema).(*v2.BatonActionSchema) a.resourceHandlers[resourceTypeID][actionName] = handler ctxzap.Extract(ctx).Debug("registered resource action", zap.String("resource_type", resourceTypeID), zap.String("action_name", actionName)) @@ -710,13 +710,13 @@ func (a *ActionManager) ListActionSchemas(_ context.Context, resourceTypeID stri // Return all global actions rv = make([]*v2.BatonActionSchema, 0, len(a.schemas)) for _, schema := range a.schemas { - rv = append(rv, schema) + rv = append(rv, proto.Clone(schema).(*v2.BatonActionSchema)) } // Also return all resource-scoped actions for _, schemas := range a.resourceSchemas { for _, schema := range schemas { - rv = append(rv, schema) + rv = append(rv, proto.Clone(schema).(*v2.BatonActionSchema)) } } } else { @@ -728,7 +728,7 @@ func (a *ActionManager) ListActionSchemas(_ context.Context, resourceTypeID stri rv = make([]*v2.BatonActionSchema, 0, len(schemas)) for _, schema := range schemas { - rv = append(rv, schema) + rv = append(rv, proto.Clone(schema).(*v2.BatonActionSchema)) } } @@ -743,7 +743,7 @@ func (a *ActionManager) GetActionSchema(_ context.Context, name string) (*v2.Bat if !ok { return nil, nil, status.Error(codes.NotFound, fmt.Sprintf("action %s not found", name)) } - return schema, nil, nil + return proto.Clone(schema).(*v2.BatonActionSchema), nil, nil } func (a *ActionManager) GetActionStatus(ctx context.Context, actionId string) (v2.BatonActionStatus, string, *structpb.Struct, annotations.Annotations, error) { diff --git a/pkg/actions/secret_content_type_test.go b/pkg/actions/secret_content_type_test.go index e4b2d0c0d..10af31cd3 100644 --- a/pkg/actions/secret_content_type_test.go +++ b/pkg/actions/secret_content_type_test.go @@ -257,3 +257,55 @@ func TestGenericSecretOutputKeepsOpaqueContexts(t *testing.T) { require.Equal(t, int32(1), calls.Load()) } } + +func TestSecretOutputAdvertisedSchemaIsAnImmutableSnapshot(t *testing.T) { + for _, resource := range []string{"", "account"} { + for _, declared := range []*string{nil, proto.String("generic"), proto.String("password_v2")} { + name := "absent" + if declared != nil { + name = *declared + } + t.Run(resource+"/"+name, func(t *testing.T) { + manager := NewActionManager(t.Context()) + var calls atomic.Int32 + schema := secretActionSchema("issue_structured") + if declared != nil { + schema.GetReturnTypes()[1].SetSecretContentType(*declared) + } + registerTypedHandler(t, manager, resource, schema, &calls) + schema.GetReturnTypes()[1].SetSecretContentType("api_key_v2") + assertDeclaration := func(field *config.Field) { + require.Equal(t, declared != nil, field.HasSecretContentType()) + if declared != nil { + require.Equal(t, *declared, field.GetSecretContentType()) + } + } + for _, filter := range []string{"", resource} { + listed, _, err := manager.ListActionSchemas(t.Context(), filter) + require.NoError(t, err) + require.Len(t, listed, 1) + assertDeclaration(listed[0].GetReturnTypes()[1]) + listed[0].GetReturnTypes()[1].SetSecretContentType("api_key_v2") + } + if resource == "" { + got, _, err := manager.GetActionSchema(t.Context(), "issue_structured") + require.NoError(t, err) + assertDeclaration(got.GetReturnTypes()[1]) + got.GetReturnTypes()[1].SetSecretContentType("api_key_v2") + got, _, err = manager.GetActionSchema(t.Context(), "issue_structured") + require.NoError(t, err) + assertDeclaration(got.GetReturnTypes()[1]) + } + context := []byte("opaque generic context") + if declared != nil && *declared != "generic" { + context = typedContext(*declared) + } + recipientConfig, _ := typedRecipient(t, "recipient", context) + _, result, _, _, _, err := manager.InvokeActionWithWaitAndEncryption(t.Context(), "issue_structured", resource, nil, time.Second, []*v2.EncryptionConfig{recipientConfig}) + require.NoError(t, err) + require.Equal(t, v2.BatonActionStatus_BATON_ACTION_STATUS_COMPLETE, result) + require.Equal(t, int32(1), calls.Load()) + }) + } + } +} From 899cb2f0457c2e40cab62e92eef3f5f88ac9f0ee Mon Sep 17 00:00:00 2001 From: Squire as Brandon High <759848+highb@users.noreply.github.com> Date: Fri, 23 Oct 2026 00:01:47 +0000 Subject: [PATCH 07/16] docs(actions): record schema snapshot regression proof Co-authored-by: c1-squire-dev[bot] --- docs/verification/connector-secret-types/evidence.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/verification/connector-secret-types/evidence.md b/docs/verification/connector-secret-types/evidence.md index 17c13535d..63642cc3b 100644 --- a/docs/verification/connector-secret-types/evidence.md +++ b/docs/verification/connector-secret-types/evidence.md @@ -1,6 +1,6 @@ # Evidence ledger -Base: released v0.42.3, commit 2e045d482d592a9462b5f87c70a6113fe87ec54a. Source declaration/guard: 528830db; generated protobuf: 9a550250. No release is claimed. +Base: released v0.42.3, commit 2e045d482d592a9462b5f87c70a6113fe87ec54a. Source declaration/guard: 528830db; generated protobuf: 9a550250; immutable advertised schema correction: 4483426d. No release is claimed. | Criterion | Executable artifact and assertion | Status | | --- | --- | --- | @@ -11,7 +11,9 @@ Base: released v0.42.3, commit 2e045d482d592a9462b5f87c70a6113fe87ec54a. Source | Legacy generic compatibility | TestGenericSecretOutputKeepsOpaqueContexts plus existing ActionHandlerWithSecrets tests; absent/explicit generic and old age multi-output/recipient paths | Verified to stated coverage | | Instrument adequacy | Removed new guard temporarily, ran account/generic regression: failed because expected error was nil; restored source byte-for-byte | Verified bounded mutation | | Source generation | make protofmt; make protogen exit0. Only config.pb.go, config.pb.validate.go, config_protoopaque.pb.go changed; generated separately | Verified | -| Full package and race | go test -race -v ./pkg/actions -count=1; all named package tests ran/pass, no skips; 11.188s package execution | Verified to stated coverage | +| Full package and race | go test -race -v ./pkg/actions -count=1; all named package tests ran/pass, no skips; 11.238s final package execution | Verified to stated coverage | | Cross-repository integration | C1 consumer adoption, real member/Store/reveal/canonical codec/renderer | Deferred to consumer integration stage; not an SDK-only claim | Initial actual invocation test failed on JWE+Paper due to the pre-existing singleton-JWE validator. That failed assumption is preserved in the change-order history; tests now assert the released contract rather than expanding native/shared validation. The caller context guard establishes agreement, not server authentication or payload admission. No provider operations were executed. + +Snapshot correction: TestSecretOutputAdvertisedSchemaIsAnImmutableSnapshot failed in all six global/resource × absent/generic/password_v2 cases before the fix. The final full race run passes original-pointer, listing-copy and getter-copy mutation controls while actual invocation agrees with the unchanged advertised declaration. Proto inputs/generated files are unchanged by this correction; no regeneration was run. From b4c6a5b06b118bf6b74cf10a5dc5a3597e83138b Mon Sep 17 00:00:00 2001 From: Squire as Brandon High <759848+highb@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:21:45 +0000 Subject: [PATCH 08/16] fix(actions): satisfy gocritic and gosec in typed secret output tests Rewrite the scenario if-else chain as a switch (gocritic ifElseChain) and scope a nolint:gosec to the fixture marshal at the G117 finding. The password value is a constant fixture payload that must survive encryption; the test pins its exact bytes, so the JSON key stays realistic. Co-authored-by: c1-squire-dev[bot] --- pkg/actions/secret_content_type_test.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/pkg/actions/secret_content_type_test.go b/pkg/actions/secret_content_type_test.go index 10af31cd3..64e585ca6 100644 --- a/pkg/actions/secret_content_type_test.go +++ b/pkg/actions/secret_content_type_test.go @@ -46,6 +46,7 @@ func registerTypedHandler(t *testing.T, manager *ActionManager, resource string, t.Helper() handler := func(context.Context, *structpb.Struct) (*structpb.Struct, []*v2.PlaintextData, annotations.Annotations, error) { calls.Add(1) + //nolint:gosec // G117: constant fixture payload; the value is not a credential. value, err := json.Marshal(struct { Username string `json:"username"` Password string `json:"password"` @@ -185,13 +186,14 @@ func TestTypedSecretOutputRefusesUnacknowledgedCallerBeforeHandler(t *testing.T) registerTypedHandler(t, manager, resource, schema, &calls) matching, _ := typedRecipient(t, "matching", typedContext("password_v2")) configs := []*v2.EncryptionConfig{matching} - if scenario == "age only" { + switch scenario { + case "age only": age, _ := ageEncryptionConfig(t) configs = []*v2.EncryptionConfig{age} - } else if scenario == "JWE plus age" { + case "JWE plus age": age, _ := ageEncryptionConfig(t) configs = append(configs, age) - } else if scenario == "conflicting JWE" { + case "conflicting JWE": conflicting, _ := typedRecipient(t, "conflicting", typedContext("api_key_v2")) configs = append(configs, conflicting) } From 76d65610ea3884ec5f98159d6ea2332623762b33 Mon Sep 17 00:00:00 2001 From: Squire as Brandon High <759848+highb@users.noreply.github.com> Date: Tue, 27 Oct 2026 23:08:32 +0000 Subject: [PATCH 09/16] Reject typed actions with multiple secret returns Co-authored-by: c1-squire-dev[bot] --- pkg/actions/secret_content_type.go | 11 +++++ pkg/actions/secret_content_type_test.go | 55 +++++++++++++++++++++++-- 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/pkg/actions/secret_content_type.go b/pkg/actions/secret_content_type.go index 7b8c086fb..91c15ae9a 100644 --- a/pkg/actions/secret_content_type.go +++ b/pkg/actions/secret_content_type.go @@ -21,7 +21,12 @@ func validateActionSecretContentTypes(schema *v2.BatonActionSchema) error { return errors.New("secret content type is only valid on secret action returns") } } + secretReturns := 0 + hasTypedReturn := false for _, field := range schema.GetReturnTypes() { + if field.GetIsSecret() { + secretReturns++ + } if !field.HasSecretContentType() { continue } @@ -29,6 +34,12 @@ func validateActionSecretContentTypes(schema *v2.BatonActionSchema) error { if !field.GetIsSecret() || len(value) > 128 || !secretContentTypePattern.MatchString(value) { return errors.New("secret action return content type is invalid") } + if value != "generic" { + hasTypedReturn = true + } + } + if hasTypedReturn && secretReturns != 1 { + return errors.New("typed secret action output requires exactly one secret return") } return nil } diff --git a/pkg/actions/secret_content_type_test.go b/pkg/actions/secret_content_type_test.go index 64e585ca6..2e989e748 100644 --- a/pkg/actions/secret_content_type_test.go +++ b/pkg/actions/secret_content_type_test.go @@ -151,6 +151,56 @@ func TestSecretContentTypeRegistrationRejectsInvalidDeclarations(t *testing.T) { } } +func TestSecretOutputRegistrationCardinality(t *testing.T) { + for _, resource := range []string{"", "account"} { + for _, tc := range []struct { + name string + contentType string + extra *config.Field + wantError bool + }{ + {"typed singleton with public return", "password_v2", nil, false}, + {"typed plus generic secret", "password_v2", config.Field_builder{Name: "other", IsSecret: true}.Build(), true}, + {"typed plus typed secret", "password_v2", config.Field_builder{Name: "other", IsSecret: true, SecretContentType: proto.String("api_key_v2")}.Build(), true}, + {"generic multiple secrets", "generic", config.Field_builder{Name: "other", IsSecret: true}.Build(), false}, + {"legacy multiple secrets", "generic", config.Field_builder{Name: "other", IsSecret: true}.Build(), false}, + {"generic plus typed secret", "generic", config.Field_builder{Name: "other", IsSecret: true, SecretContentType: proto.String("api_key_v2")}.Build(), true}, + } { + t.Run(resource+"/"+tc.name, func(t *testing.T) { + manager := NewActionManager(t.Context()) + schema := typedSecretSchema(tc.contentType) + if tc.name == "legacy multiple secrets" { + schema.GetReturnTypes()[1].ClearSecretContentType() + } + if tc.extra != nil { + schema.SetReturnTypes(append(schema.GetReturnTypes(), tc.extra)) + } + handler := func(context.Context, *structpb.Struct) (*structpb.Struct, []*v2.PlaintextData, annotations.Annotations, error) { + return nil, nil, nil, nil + } + var err error + if resource == "" { + err = manager.RegisterWithSecrets(t.Context(), schema, handler) + } else { + err = manager.RegisterResourceActionWithSecrets(t.Context(), resource, schema, handler) + } + if tc.wantError { + require.ErrorContains(t, err, "exactly one secret return") + require.False(t, manager.HasActions()) + } else { + require.NoError(t, err) + require.True(t, manager.HasActions()) + } + }) + } + } +} + +func TestTypedOutputContextRetainsCardinalityGuard(t *testing.T) { + err := validateTypedOutputContext(map[string]string{"one": "password_v2", "two": "generic"}, nil) + require.ErrorContains(t, err, "exactly one secret return") +} + func TestTypedSecretOutputRefusesUnacknowledgedCallerBeforeHandler(t *testing.T) { for _, resource := range []string{"", "account"} { for _, tc := range []struct{ name, context string }{ @@ -175,14 +225,11 @@ func TestTypedSecretOutputRefusesUnacknowledgedCallerBeforeHandler(t *testing.T) require.Empty(t, manager.actions) }) } - for _, scenario := range []string{"age only", "conflicting JWE", "JWE plus age", "multiple secrets"} { + for _, scenario := range []string{"age only", "conflicting JWE", "JWE plus age"} { t.Run(resource+"/"+scenario, func(t *testing.T) { manager := NewActionManager(t.Context()) var calls atomic.Int32 schema := typedSecretSchema("password_v2") - if scenario == "multiple secrets" { - schema.SetReturnTypes(append(schema.GetReturnTypes(), config.Field_builder{Name: "other", IsSecret: true}.Build())) - } registerTypedHandler(t, manager, resource, schema, &calls) matching, _ := typedRecipient(t, "matching", typedContext("password_v2")) configs := []*v2.EncryptionConfig{matching} From 32121b0ec0b0df2e3c3fdeec87910b95a2f7a58d Mon Sep 17 00:00:00 2001 From: Squire as Brandon High <759848+highb@users.noreply.github.com> Date: Fri, 30 Oct 2026 23:56:34 +0000 Subject: [PATCH 10/16] Clarify secret content type configuration semantics Co-authored-by: c1-squire-dev[bot] --- proto/c1/config/v1/config.proto | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/proto/c1/config/v1/config.proto b/proto/c1/config/v1/config.proto index bce369676..83ed697aa 100644 --- a/proto/c1/config/v1/config.proto +++ b/proto/c1/config/v1/config.proto @@ -59,8 +59,9 @@ message Field { // through ActionHandlerWithSecrets; it is omitted from the public response // and returned as EncryptedData. bool is_secret = 7; - // Secret action return content type. Absent means generic; not valid on - // configuration fields, action arguments, or public returns. + // Content type for secret action return fields; absent means generic. + // Action registration rejects it on arguments and non-secret returns. + // It has no configuration-field semantics; syntax validation still applies. optional string secret_content_type = 8 [(validate.rules).string = { min_len: 1 max_len: 128 From 020da7412067d4a6933b7a0fadca7d695c0df83e Mon Sep 17 00:00:00 2001 From: Squire as Brandon High <759848+highb@users.noreply.github.com> Date: Fri, 30 Oct 2026 23:56:45 +0000 Subject: [PATCH 11/16] Regenerate clarified secret content type comments Co-authored-by: c1-squire-dev[bot] --- pb/c1/config/v1/config.pb.go | 10 ++++++---- pb/c1/config/v1/config_protoopaque.pb.go | 5 +++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/pb/c1/config/v1/config.pb.go b/pb/c1/config/v1/config.pb.go index 07950ec3a..ad7cff744 100644 --- a/pb/c1/config/v1/config.pb.go +++ b/pb/c1/config/v1/config.pb.go @@ -565,8 +565,9 @@ type Field struct { // through ActionHandlerWithSecrets; it is omitted from the public response // and returned as EncryptedData. IsSecret bool `protobuf:"varint,7,opt,name=is_secret,json=isSecret,proto3" json:"is_secret,omitempty"` - // Secret action return content type. Absent means generic; not valid on - // configuration fields, action arguments, or public returns. + // Content type for secret action return fields; absent means generic. + // Action registration rejects it on arguments and non-secret returns. + // It has no configuration-field semantics; syntax validation still applies. SecretContentType *string `protobuf:"bytes,8,opt,name=secret_content_type,json=secretContentType,proto3,oneof" json:"secret_content_type,omitempty"` // Types that are valid to be assigned to Field: // @@ -1128,8 +1129,9 @@ type Field_builder struct { // through ActionHandlerWithSecrets; it is omitted from the public response // and returned as EncryptedData. IsSecret bool - // Secret action return content type. Absent means generic; not valid on - // configuration fields, action arguments, or public returns. + // Content type for secret action return fields; absent means generic. + // Action registration rejects it on arguments and non-secret returns. + // It has no configuration-field semantics; syntax validation still applies. SecretContentType *string // Fields of oneof Field: StringField *StringField diff --git a/pb/c1/config/v1/config_protoopaque.pb.go b/pb/c1/config/v1/config_protoopaque.pb.go index 3ba1edf5d..dd6e39fee 100644 --- a/pb/c1/config/v1/config_protoopaque.pb.go +++ b/pb/c1/config/v1/config_protoopaque.pb.go @@ -1115,8 +1115,9 @@ type Field_builder struct { // through ActionHandlerWithSecrets; it is omitted from the public response // and returned as EncryptedData. IsSecret bool - // Secret action return content type. Absent means generic; not valid on - // configuration fields, action arguments, or public returns. + // Content type for secret action return fields; absent means generic. + // Action registration rejects it on arguments and non-secret returns. + // It has no configuration-field semantics; syntax validation still applies. SecretContentType *string // Fields of oneof xxx_hidden_Field: StringField *StringField From 24b852f4934d113b02672a3d1ad1ebf93ea6911c Mon Sep 17 00:00:00 2001 From: Geoff Greer Date: Mon, 2 Nov 2026 12:58:44 -0800 Subject: [PATCH 12/16] Add content_type and tags to fields. Content_type is an optional mime content type on fields. It's only relevant for StringField, StringSliceField, and StringMapField. Tags are used to classify fields. eg: If a connector action returns an encrypted field that is a credential for multipass, the tags could be, "credential", "multipass", & "api_key_v2". --- pb/c1/config/v1/config.pb.go | 39 ++++++++++- pb/c1/config/v1/config.pb.validate.go | 83 ++++++++++++++++++++++++ pb/c1/config/v1/config_protoopaque.pb.go | 38 ++++++++++- proto/c1/config/v1/config.proto | 21 ++++++ 4 files changed, 175 insertions(+), 6 deletions(-) diff --git a/pb/c1/config/v1/config.pb.go b/pb/c1/config/v1/config.pb.go index bfadc34e3..b9ae5509e 100644 --- a/pb/c1/config/v1/config.pb.go +++ b/pb/c1/config/v1/config.pb.go @@ -9,6 +9,7 @@ package v1 import ( + _ "github.com/envoyproxy/protoc-gen-validate/validate" protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" anypb "google.golang.org/protobuf/types/known/anypb" @@ -564,6 +565,9 @@ type Field struct { // through ActionHandlerWithSecrets; it is omitted from the public response // and returned as EncryptedData. IsSecret bool `protobuf:"varint,7,opt,name=is_secret,json=isSecret,proto3" json:"is_secret,omitempty"` + // Optional: the content type of the field. Only valid for StringField, StringSliceField, & StringMapField. + ContentType string `protobuf:"bytes,8,opt,name=content_type,json=contentType,proto3" json:"content_type,omitempty"` + Tags []string `protobuf:"bytes,9,rep,name=tags,proto3" json:"tags,omitempty"` // Types that are valid to be assigned to Field: // // *Field_StringField @@ -656,6 +660,20 @@ func (x *Field) GetIsSecret() bool { return false } +func (x *Field) GetContentType() string { + if x != nil { + return x.ContentType + } + return "" +} + +func (x *Field) GetTags() []string { + if x != nil { + return x.Tags + } + return nil +} + func (x *Field) GetField() isField_Field { if x != nil { return x.Field @@ -790,6 +808,14 @@ func (x *Field) SetIsSecret(v bool) { x.IsSecret = v } +func (x *Field) SetContentType(v string) { + x.ContentType = v +} + +func (x *Field) SetTags(v []string) { + x.Tags = v +} + func (x *Field) SetStringField(v *StringField) { if v == nil { x.Field = nil @@ -1102,6 +1128,9 @@ type Field_builder struct { // through ActionHandlerWithSecrets; it is omitted from the public response // and returned as EncryptedData. IsSecret bool + // Optional: the content type of the field. Only valid for StringField, StringSliceField, & StringMapField. + ContentType string + Tags []string // Fields of oneof Field: StringField *StringField IntField *IntField @@ -1129,6 +1158,8 @@ func (b0 Field_builder) Build() *Field { x.IsRequired = b.IsRequired x.IsOps = b.IsOps x.IsSecret = b.IsSecret + x.ContentType = b.ContentType + x.Tags = b.Tags if b.StringField != nil { x.Field = &Field_StringField{b.StringField} } @@ -2805,7 +2836,7 @@ var File_c1_config_v1_config_proto protoreflect.FileDescriptor const file_c1_config_v1_config_proto_rawDesc = "" + "\n" + - "\x19c1/config/v1/config.proto\x12\fc1.config.v1\x1a\x18c1/config/v1/rules.proto\x1a\x19google/protobuf/any.proto\"\xd0\x03\n" + + "\x19c1/config/v1/config.proto\x12\fc1.config.v1\x1a\x18c1/config/v1/rules.proto\x1a\x19google/protobuf/any.proto\x1a\x17validate/validate.proto\"\xd0\x03\n" + "\rConfiguration\x12+\n" + "\x06fields\x18\x01 \x03(\v2\x13.c1.config.v1.FieldR\x06fields\x12:\n" + "\vconstraints\x18\x02 \x03(\v2\x18.c1.config.v1.ConstraintR\vconstraints\x12!\n" + @@ -2834,7 +2865,7 @@ const file_c1_config_v1_config_proto_rawDesc = "" + "\fdisplay_name\x18\x02 \x01(\tR\vdisplayName\x12\x1b\n" + "\thelp_text\x18\x03 \x01(\tR\bhelpText\x12\x16\n" + "\x06fields\x18\x04 \x03(\tR\x06fields\x12\x18\n" + - "\adefault\x18\x05 \x01(\bR\adefault\"\x9d\b\n" + + "\adefault\x18\x05 \x01(\bR\adefault\"\x99\t\n" + "\x05Field\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12!\n" + "\fdisplay_name\x18\x02 \x01(\tR\vdisplayName\x12 \n" + @@ -2843,7 +2874,9 @@ const file_c1_config_v1_config_proto_rawDesc = "" + "\vis_required\x18\x05 \x01(\bR\n" + "isRequired\x12\x15\n" + "\x06is_ops\x18\x06 \x01(\bR\x05isOps\x12\x1b\n" + - "\tis_secret\x18\a \x01(\bR\bisSecret\x12>\n" + + "\tis_secret\x18\a \x01(\bR\bisSecret\x12?\n" + + "\fcontent_type\x18\b \x01(\tB\x1c\xfaB\x19r\x17\x10\x01\x18\x80\x012\r^[^/]+/[^/]+$\xd0\x01\x01R\vcontentType\x129\n" + + "\x04tags\x18\t \x03(\tB%\xfaB\"\x92\x01\x1f\x10\x10\x18\x01\"\x19r\x17\x10\x01\x18\x80\x012\x10^[a-zA-Z0-9_-]+$R\x04tags\x12>\n" + "\fstring_field\x18d \x01(\v2\x19.c1.config.v1.StringFieldH\x00R\vstringField\x125\n" + "\tint_field\x18e \x01(\v2\x16.c1.config.v1.IntFieldH\x00R\bintField\x128\n" + "\n" + diff --git a/pb/c1/config/v1/config.pb.validate.go b/pb/c1/config/v1/config.pb.validate.go index 6cacb6839..2e5f117ae 100644 --- a/pb/c1/config/v1/config.pb.validate.go +++ b/pb/c1/config/v1/config.pb.validate.go @@ -500,6 +500,85 @@ func (m *Field) validate(all bool) error { // no validation rules for IsSecret + if m.GetContentType() != "" { + + if l := utf8.RuneCountInString(m.GetContentType()); l < 1 || l > 128 { + err := FieldValidationError{ + field: "ContentType", + reason: "value length must be between 1 and 128 runes, inclusive", + } + if !all { + return err + } + errors = append(errors, err) + } + + if !_Field_ContentType_Pattern.MatchString(m.GetContentType()) { + err := FieldValidationError{ + field: "ContentType", + reason: "value does not match regex pattern \"^[^/]+/[^/]+$\"", + } + if !all { + return err + } + errors = append(errors, err) + } + + } + + if len(m.GetTags()) > 16 { + err := FieldValidationError{ + field: "Tags", + reason: "value must contain no more than 16 item(s)", + } + if !all { + return err + } + errors = append(errors, err) + } + + _Field_Tags_Unique := make(map[string]struct{}, len(m.GetTags())) + + for idx, item := range m.GetTags() { + _, _ = idx, item + + if _, exists := _Field_Tags_Unique[item]; exists { + err := FieldValidationError{ + field: fmt.Sprintf("Tags[%v]", idx), + reason: "repeated value must contain unique items", + } + if !all { + return err + } + errors = append(errors, err) + } else { + _Field_Tags_Unique[item] = struct{}{} + } + + if l := utf8.RuneCountInString(item); l < 1 || l > 128 { + err := FieldValidationError{ + field: fmt.Sprintf("Tags[%v]", idx), + reason: "value length must be between 1 and 128 runes, inclusive", + } + if !all { + return err + } + errors = append(errors, err) + } + + if !_Field_Tags_Pattern.MatchString(item) { + err := FieldValidationError{ + field: fmt.Sprintf("Tags[%v]", idx), + reason: "value does not match regex pattern \"^[a-zA-Z0-9_-]+$\"", + } + if !all { + return err + } + errors = append(errors, err) + } + + } + switch v := m.Field.(type) { case *Field_StringField: if v == nil { @@ -1033,6 +1112,10 @@ var _ interface { ErrorName() string } = FieldValidationError{} +var _Field_ContentType_Pattern = regexp.MustCompile("^[^/]+/[^/]+$") + +var _Field_Tags_Pattern = regexp.MustCompile("^[a-zA-Z0-9_-]+$") + // Validate checks the field values on Resource with the rules defined in the // proto definition for this message. If any rules are violated, the first // error encountered is returned, or nil if there are no violations. diff --git a/pb/c1/config/v1/config_protoopaque.pb.go b/pb/c1/config/v1/config_protoopaque.pb.go index 96ead9af2..6670823e1 100644 --- a/pb/c1/config/v1/config_protoopaque.pb.go +++ b/pb/c1/config/v1/config_protoopaque.pb.go @@ -9,6 +9,7 @@ package v1 import ( + _ "github.com/envoyproxy/protoc-gen-validate/validate" protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" anypb "google.golang.org/protobuf/types/known/anypb" @@ -566,6 +567,8 @@ type Field struct { xxx_hidden_IsRequired bool `protobuf:"varint,5,opt,name=is_required,json=isRequired,proto3"` xxx_hidden_IsOps bool `protobuf:"varint,6,opt,name=is_ops,json=isOps,proto3"` xxx_hidden_IsSecret bool `protobuf:"varint,7,opt,name=is_secret,json=isSecret,proto3"` + xxx_hidden_ContentType string `protobuf:"bytes,8,opt,name=content_type,json=contentType,proto3"` + xxx_hidden_Tags []string `protobuf:"bytes,9,rep,name=tags,proto3"` xxx_hidden_Field isField_Field `protobuf_oneof:"field"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -645,6 +648,20 @@ func (x *Field) GetIsSecret() bool { return false } +func (x *Field) GetContentType() string { + if x != nil { + return x.xxx_hidden_ContentType + } + return "" +} + +func (x *Field) GetTags() []string { + if x != nil { + return x.xxx_hidden_Tags + } + return nil +} + func (x *Field) GetStringField() *StringField { if x != nil { if x, ok := x.xxx_hidden_Field.(*field_StringField); ok { @@ -772,6 +789,14 @@ func (x *Field) SetIsSecret(v bool) { x.xxx_hidden_IsSecret = v } +func (x *Field) SetContentType(v string) { + x.xxx_hidden_ContentType = v +} + +func (x *Field) SetTags(v []string) { + x.xxx_hidden_Tags = v +} + func (x *Field) SetStringField(v *StringField) { if v == nil { x.xxx_hidden_Field = nil @@ -1084,6 +1109,9 @@ type Field_builder struct { // through ActionHandlerWithSecrets; it is omitted from the public response // and returned as EncryptedData. IsSecret bool + // Optional: the content type of the field. Only valid for StringField, StringSliceField, & StringMapField. + ContentType string + Tags []string // Fields of oneof xxx_hidden_Field: StringField *StringField IntField *IntField @@ -1111,6 +1139,8 @@ func (b0 Field_builder) Build() *Field { x.xxx_hidden_IsRequired = b.IsRequired x.xxx_hidden_IsOps = b.IsOps x.xxx_hidden_IsSecret = b.IsSecret + x.xxx_hidden_ContentType = b.ContentType + x.xxx_hidden_Tags = b.Tags if b.StringField != nil { x.xxx_hidden_Field = &field_StringField{b.StringField} } @@ -2797,7 +2827,7 @@ var File_c1_config_v1_config_proto protoreflect.FileDescriptor const file_c1_config_v1_config_proto_rawDesc = "" + "\n" + - "\x19c1/config/v1/config.proto\x12\fc1.config.v1\x1a\x18c1/config/v1/rules.proto\x1a\x19google/protobuf/any.proto\"\xd0\x03\n" + + "\x19c1/config/v1/config.proto\x12\fc1.config.v1\x1a\x18c1/config/v1/rules.proto\x1a\x19google/protobuf/any.proto\x1a\x17validate/validate.proto\"\xd0\x03\n" + "\rConfiguration\x12+\n" + "\x06fields\x18\x01 \x03(\v2\x13.c1.config.v1.FieldR\x06fields\x12:\n" + "\vconstraints\x18\x02 \x03(\v2\x18.c1.config.v1.ConstraintR\vconstraints\x12!\n" + @@ -2826,7 +2856,7 @@ const file_c1_config_v1_config_proto_rawDesc = "" + "\fdisplay_name\x18\x02 \x01(\tR\vdisplayName\x12\x1b\n" + "\thelp_text\x18\x03 \x01(\tR\bhelpText\x12\x16\n" + "\x06fields\x18\x04 \x03(\tR\x06fields\x12\x18\n" + - "\adefault\x18\x05 \x01(\bR\adefault\"\x9d\b\n" + + "\adefault\x18\x05 \x01(\bR\adefault\"\x99\t\n" + "\x05Field\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12!\n" + "\fdisplay_name\x18\x02 \x01(\tR\vdisplayName\x12 \n" + @@ -2835,7 +2865,9 @@ const file_c1_config_v1_config_proto_rawDesc = "" + "\vis_required\x18\x05 \x01(\bR\n" + "isRequired\x12\x15\n" + "\x06is_ops\x18\x06 \x01(\bR\x05isOps\x12\x1b\n" + - "\tis_secret\x18\a \x01(\bR\bisSecret\x12>\n" + + "\tis_secret\x18\a \x01(\bR\bisSecret\x12?\n" + + "\fcontent_type\x18\b \x01(\tB\x1c\xfaB\x19r\x17\x10\x01\x18\x80\x012\r^[^/]+/[^/]+$\xd0\x01\x01R\vcontentType\x129\n" + + "\x04tags\x18\t \x03(\tB%\xfaB\"\x92\x01\x1f\x10\x10\x18\x01\"\x19r\x17\x10\x01\x18\x80\x012\x10^[a-zA-Z0-9_-]+$R\x04tags\x12>\n" + "\fstring_field\x18d \x01(\v2\x19.c1.config.v1.StringFieldH\x00R\vstringField\x125\n" + "\tint_field\x18e \x01(\v2\x16.c1.config.v1.IntFieldH\x00R\bintField\x128\n" + "\n" + diff --git a/proto/c1/config/v1/config.proto b/proto/c1/config/v1/config.proto index 29f3c4ca6..cbe7d4a81 100644 --- a/proto/c1/config/v1/config.proto +++ b/proto/c1/config/v1/config.proto @@ -4,6 +4,7 @@ package c1.config.v1; import "c1/config/v1/rules.proto"; import "google/protobuf/any.proto"; +import "validate/validate.proto"; option go_package = "github.com/conductorone/baton-sdk/pb/c1/config/v1"; @@ -59,6 +60,26 @@ message Field { // and returned as EncryptedData. bool is_secret = 7; + // Optional: the content type of the field. Only valid for StringField, StringSliceField, & StringMapField. + string content_type = 8 [(validate.rules).string = { + ignore_empty: true + min_len: 1 + max_len: 128 + pattern: "^[^/]+/[^/]+$" + }]; + + repeated string tags = 9 [(validate.rules).repeated = { + unique: true + items: { + string: { + min_len: 1 + max_len: 128 + pattern: "^[a-zA-Z0-9_-]+$" + } + } + max_items: 16 + }]; + oneof field { StringField string_field = 100; IntField int_field = 101; From 21fe49fc009a8c5989e5f70b534dc5cf51e976e4 Mon Sep 17 00:00:00 2001 From: Brandon High <759848+highb@users.noreply.github.com> Date: Mon, 2 Nov 2026 23:33:20 +0000 Subject: [PATCH 13/16] Use shared field metadata for secret outputs Co-authored-by: c1-squire-dev[bot] --- .../field-metadata-transition.md | 15 ++++ pkg/actions/secret_content_type.go | 84 ++++--------------- pkg/actions/secret_content_type_test.go | 71 +++++++++++----- pkg/crypto/field_metadata.go | 58 +++++++++++++ pkg/crypto/secret_content_type.go | 59 +++++++++++++ pkg/lambda/grpc/transport.go | 66 ++++++++++++++- pkg/lambda/grpc/wire_test.go | 18 ++++ proto/c1/config/v1/config.proto | 13 +-- 8 files changed, 289 insertions(+), 95 deletions(-) create mode 100644 docs/verification/connector-secret-types/field-metadata-transition.md create mode 100644 pkg/crypto/field_metadata.go create mode 100644 pkg/crypto/secret_content_type.go diff --git a/docs/verification/connector-secret-types/field-metadata-transition.md b/docs/verification/connector-secret-types/field-metadata-transition.md new file mode 100644 index 000000000..8ee9c35d9 --- /dev/null +++ b/docs/verification/connector-secret-types/field-metadata-transition.md @@ -0,0 +1,15 @@ +# Field metadata transition + +`config.Field.content_type` is MIME metadata. Logical delivery types use one +`multipass-type-` tag, for example `multipass-type-api_key_v2`. Other tags do +not select a type. The earlier prototype used Field 8 for a bare logical type; +that encoding is incompatible and valid bare values are rejected. JSON using +`secret_content_type` on a Field is also rejected by the legacy transport. +Native `request_schema` and descriptor `secret_content_type` remain readable; +when new and legacy declarations coexist they must agree. An absent return +field preserves the legacy issuance contract. A declared native return is one +named required secret string whose name must match the plaintext result. + +Request values remain nonsecret. Hosts apply defaults before approval; SDK +validation does not apply defaults. Payload bytes are opaque and are not +validated against MIME or logical type metadata. diff --git a/pkg/actions/secret_content_type.go b/pkg/actions/secret_content_type.go index 91c15ae9a..45a788385 100644 --- a/pkg/actions/secret_content_type.go +++ b/pkg/actions/secret_content_type.go @@ -1,40 +1,39 @@ package actions import ( - "bytes" - "encoding/json" "errors" - "io" - "regexp" "strings" config "github.com/conductorone/baton-sdk/pb/c1/config/v1" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/crypto" "github.com/conductorone/baton-sdk/pkg/crypto/providers/jwe" ) -var secretContentTypePattern = regexp.MustCompile(`^[a-z0-9]+([._-][a-z0-9]+)*$`) - func validateActionSecretContentTypes(schema *v2.BatonActionSchema) error { for _, field := range schema.GetArguments() { - if field.HasSecretContentType() { - return errors.New("secret content type is only valid on secret action returns") + meta, err := crypto.ResolveFieldOutput(field) + if err != nil { + return err + } + if meta.HasType { + return errors.New("logical type is only valid on secret action returns") } } secretReturns := 0 hasTypedReturn := false for _, field := range schema.GetReturnTypes() { + meta, err := crypto.ResolveFieldOutput(field) + if err != nil { + return err + } if field.GetIsSecret() { secretReturns++ } - if !field.HasSecretContentType() { - continue - } - value := field.GetSecretContentType() - if !field.GetIsSecret() || len(value) > 128 || !secretContentTypePattern.MatchString(value) { - return errors.New("secret action return content type is invalid") + if meta.HasType && !field.GetIsSecret() { + return errors.New("logical type is only valid on secret action returns") } - if value != "generic" { + if meta.HasType && meta.LogicalType != "generic" { hasTypedReturn = true } } @@ -47,14 +46,10 @@ func validateActionSecretContentTypes(schema *v2.BatonActionSchema) error { func snapshotSecretContentTypes(fields []*config.Field) map[string]string { types := make(map[string]string) for _, field := range fields { - if !field.GetIsSecret() { - continue - } - value := "generic" - if field.HasSecretContentType() { - value = field.GetSecretContentType() + if field.GetIsSecret() { + meta, _ := crypto.ResolveFieldOutput(field) + types[field.GetName()] = meta.LogicalType } - types[field.GetName()] = value } return types } @@ -78,7 +73,7 @@ func validateTypedOutputContext(types map[string]string, configs []*v2.Encryptio if strings.ToLower(strings.TrimSpace(config.GetProvider())) != jwe.EncryptionProvider { continue } - if !typedOutputContextMatches(config.GetJwkPublicKeyConfig().GetAdditionalAuthenticatedData(), contentType) { + if !crypto.TypedOutputContextMatches(config.GetJwkPublicKeyConfig().GetAdditionalAuthenticatedData(), contentType) { return errors.New("typed secret action output requires a matching full-knowledge recipient context") } matched = true @@ -88,46 +83,3 @@ func validateTypedOutputContext(types map[string]string, configs []*v2.Encryptio } return nil } - -func typedOutputContextMatches(encoded []byte, contentType string) bool { - if len(encoded) == 0 || len(encoded) > 16*1024 { - return false - } - decoder := json.NewDecoder(bytes.NewReader(encoded)) - token, err := decoder.Token() - if err != nil || token != json.Delim('{') { - return false - } - fields := make(map[string]json.RawMessage) - for decoder.More() { - token, err := decoder.Token() - if err != nil { - return false - } - name, ok := token.(string) - if !ok { - return false - } - if _, duplicate := fields[name]; duplicate { - return false - } - var value json.RawMessage - if err := decoder.Decode(&value); err != nil { - return false - } - fields[name] = value - } - if token, err := decoder.Token(); err != nil || token != json.Delim('}') { - return false - } - if _, err := decoder.Token(); err != io.EOF { - return false - } - var purpose, actualType string - var version uint32 - if json.Unmarshal(fields["purpose"], &purpose) != nil || json.Unmarshal(fields["version"], &version) != nil || - json.Unmarshal(fields["content_type"], &actualType) != nil { - return false - } - return purpose == "c1/fk-credential-delivery" && version == 1 && actualType == contentType -} diff --git a/pkg/actions/secret_content_type_test.go b/pkg/actions/secret_content_type_test.go index 2e989e748..c9a5ede9e 100644 --- a/pkg/actions/secret_content_type_test.go +++ b/pkg/actions/secret_content_type_test.go @@ -17,13 +17,18 @@ import ( "github.com/conductorone/baton-sdk/pkg/crypto/providers/jwe" "github.com/stretchr/testify/require" "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/encoding/protowire" "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/structpb" ) +func setLogicalType(field *config.Field, value string) { + field.SetTags([]string{"multipass-type-" + value}) +} + func typedSecretSchema(contentType string) *v2.BatonActionSchema { schema := secretActionSchema("issue_structured") - schema.GetReturnTypes()[1].SetSecretContentType(contentType) + setLogicalType(schema.GetReturnTypes()[1], contentType) return schema } @@ -67,7 +72,7 @@ func TestSecretContentTypeSchemaRoundTrip(t *testing.T) { for _, present := range []bool{false, true} { schema := secretActionSchema("schema") if present { - schema.GetReturnTypes()[1].SetSecretContentType("password_v2") + setLogicalType(schema.GetReturnTypes()[1], "password_v2") } for _, encoding := range []string{"binary", "JSON"} { t.Run(fmt.Sprintf("present=%t/%s", present, encoding), func(t *testing.T) { @@ -87,13 +92,13 @@ func TestSecretContentTypeSchemaRoundTrip(t *testing.T) { } require.NoError(t, err) require.True(t, proto.Equal(schema, &decoded)) - require.Equal(t, present, decoded.GetReturnTypes()[1].HasSecretContentType()) + require.Equal(t, present, len(decoded.GetReturnTypes()[1].GetTags()) > 0) }) } } field := typedSecretSchema("").GetReturnTypes()[1] - require.True(t, field.HasSecretContentType()) - require.Error(t, field.ValidateAll()) + require.True(t, len(field.GetTags()) > 0) + require.Error(t, validateActionSecretContentTypes(typedSecretSchema(""))) } func TestSecretContentTypeRegistrationRejectsInvalidDeclarations(t *testing.T) { @@ -102,13 +107,13 @@ func TestSecretContentTypeRegistrationRejectsInvalidDeclarations(t *testing.T) { name string mutate func(*v2.BatonActionSchema) }{ - {"empty", func(s *v2.BatonActionSchema) { s.GetReturnTypes()[1].SetSecretContentType("") }}, - {"uppercase", func(s *v2.BatonActionSchema) { s.GetReturnTypes()[1].SetSecretContentType("Password_V2") }}, - {"whitespace", func(s *v2.BatonActionSchema) { s.GetReturnTypes()[1].SetSecretContentType(" password_v2") }}, - {"overlong", func(s *v2.BatonActionSchema) { s.GetReturnTypes()[1].SetSecretContentType(strings.Repeat("a", 129)) }}, - {"public return", func(s *v2.BatonActionSchema) { s.GetReturnTypes()[0].SetSecretContentType("password_v2") }}, + {"empty", func(s *v2.BatonActionSchema) { setLogicalType(s.GetReturnTypes()[1], "") }}, + {"uppercase", func(s *v2.BatonActionSchema) { setLogicalType(s.GetReturnTypes()[1], "Password_V2") }}, + {"whitespace", func(s *v2.BatonActionSchema) { setLogicalType(s.GetReturnTypes()[1], " password_v2") }}, + {"overlong", func(s *v2.BatonActionSchema) { setLogicalType(s.GetReturnTypes()[1], strings.Repeat("a", 129)) }}, + {"public return", func(s *v2.BatonActionSchema) { setLogicalType(s.GetReturnTypes()[0], "password_v2") }}, {"input", func(s *v2.BatonActionSchema) { - s.SetArguments([]*config.Field{config.Field_builder{Name: "input", IsSecret: true, SecretContentType: proto.String("password_v2")}.Build()}) + s.SetArguments([]*config.Field{config.Field_builder{Name: "input", IsSecret: true, Tags: []string{"multipass-type-password_v2"}}.Build()}) }}, } { t.Run(resource+"/"+tc.name, func(t *testing.T) { @@ -134,7 +139,7 @@ func TestSecretContentTypeRegistrationRejectsInvalidDeclarations(t *testing.T) { manager := NewActionManager(t.Context()) schema := secretActionSchema("public") schema.SetReturnTypes(schema.GetReturnTypes()[:1]) - schema.GetReturnTypes()[0].SetSecretContentType("password_v2") + setLogicalType(schema.GetReturnTypes()[0], "password_v2") handler := func(context.Context, *structpb.Struct) (*structpb.Struct, annotations.Annotations, error) { return nil, nil, nil } @@ -161,16 +166,16 @@ func TestSecretOutputRegistrationCardinality(t *testing.T) { }{ {"typed singleton with public return", "password_v2", nil, false}, {"typed plus generic secret", "password_v2", config.Field_builder{Name: "other", IsSecret: true}.Build(), true}, - {"typed plus typed secret", "password_v2", config.Field_builder{Name: "other", IsSecret: true, SecretContentType: proto.String("api_key_v2")}.Build(), true}, + {"typed plus typed secret", "password_v2", config.Field_builder{Name: "other", IsSecret: true, Tags: []string{"multipass-type-api_key_v2"}}.Build(), true}, {"generic multiple secrets", "generic", config.Field_builder{Name: "other", IsSecret: true}.Build(), false}, {"legacy multiple secrets", "generic", config.Field_builder{Name: "other", IsSecret: true}.Build(), false}, - {"generic plus typed secret", "generic", config.Field_builder{Name: "other", IsSecret: true, SecretContentType: proto.String("api_key_v2")}.Build(), true}, + {"generic plus typed secret", "generic", config.Field_builder{Name: "other", IsSecret: true, Tags: []string{"multipass-type-api_key_v2"}}.Build(), true}, } { t.Run(resource+"/"+tc.name, func(t *testing.T) { manager := NewActionManager(t.Context()) schema := typedSecretSchema(tc.contentType) if tc.name == "legacy multiple secrets" { - schema.GetReturnTypes()[1].ClearSecretContentType() + schema.GetReturnTypes()[1].SetTags(nil) } if tc.extra != nil { schema.SetReturnTypes(append(schema.GetReturnTypes(), tc.extra)) @@ -261,7 +266,7 @@ func TestTypedSecretOutputEncryptsExactHelperBytesWithFrozenDeclaration(t *testi var calls atomic.Int32 schema := typedSecretSchema("password_v2") registerTypedHandler(t, manager, resource, schema, &calls) - schema.GetReturnTypes()[1].SetSecretContentType("api_key_v2") + setLogicalType(schema.GetReturnTypes()[1], "api_key_v2") config, private := typedRecipient(t, "recipient", typedContext("password_v2")) id, result, public, encrypted, _, err := manager.InvokeActionWithWaitAndEncryption(t.Context(), "issue_structured", resource, nil, time.Second, []*v2.EncryptionConfig{config}) require.NoError(t, err) @@ -296,7 +301,7 @@ func TestGenericSecretOutputKeepsOpaqueContexts(t *testing.T) { var calls atomic.Int32 schema := secretActionSchema("issue_structured") if explicit { - schema.GetReturnTypes()[1].SetSecretContentType("generic") + setLogicalType(schema.GetReturnTypes()[1], "generic") } registerTypedHandler(t, manager, "", schema, &calls) config, _ := typedRecipient(t, "recipient", []byte("opaque legacy context")) @@ -319,14 +324,14 @@ func TestSecretOutputAdvertisedSchemaIsAnImmutableSnapshot(t *testing.T) { var calls atomic.Int32 schema := secretActionSchema("issue_structured") if declared != nil { - schema.GetReturnTypes()[1].SetSecretContentType(*declared) + setLogicalType(schema.GetReturnTypes()[1], *declared) } registerTypedHandler(t, manager, resource, schema, &calls) - schema.GetReturnTypes()[1].SetSecretContentType("api_key_v2") + setLogicalType(schema.GetReturnTypes()[1], "api_key_v2") assertDeclaration := func(field *config.Field) { - require.Equal(t, declared != nil, field.HasSecretContentType()) + require.Equal(t, declared != nil, len(field.GetTags()) > 0) if declared != nil { - require.Equal(t, *declared, field.GetSecretContentType()) + require.Equal(t, *declared, strings.TrimPrefix(field.GetTags()[0], "multipass-type-")) } } for _, filter := range []string{"", resource} { @@ -334,13 +339,13 @@ func TestSecretOutputAdvertisedSchemaIsAnImmutableSnapshot(t *testing.T) { require.NoError(t, err) require.Len(t, listed, 1) assertDeclaration(listed[0].GetReturnTypes()[1]) - listed[0].GetReturnTypes()[1].SetSecretContentType("api_key_v2") + setLogicalType(listed[0].GetReturnTypes()[1], "api_key_v2") } if resource == "" { got, _, err := manager.GetActionSchema(t.Context(), "issue_structured") require.NoError(t, err) assertDeclaration(got.GetReturnTypes()[1]) - got.GetReturnTypes()[1].SetSecretContentType("api_key_v2") + setLogicalType(got.GetReturnTypes()[1], "api_key_v2") got, _, err = manager.GetActionSchema(t.Context(), "issue_structured") require.NoError(t, err) assertDeclaration(got.GetReturnTypes()[1]) @@ -358,3 +363,23 @@ func TestSecretOutputAdvertisedSchemaIsAnImmutableSnapshot(t *testing.T) { } } } + +func TestLegacyBinaryFieldEightRejectedAtRegistration(t *testing.T) { + for _, value := range []string{"generic", "password_v2"} { + schema := secretActionSchema("legacy") + field := schema.GetReturnTypes()[1] + // Field 8 was a length-delimited string in the prototype. + var decoded config.Field + encoded := protowire.AppendString(protowire.AppendTag(nil, 8, protowire.BytesType), value) + require.NoError(t, proto.Unmarshal(encoded, &decoded)) + field.SetContentType(decoded.GetContentType()) + manager := NewActionManager(t.Context()) + var calls atomic.Int32 + err := manager.RegisterWithSecrets(t.Context(), schema, func(context.Context, *structpb.Struct) (*structpb.Struct, []*v2.PlaintextData, annotations.Annotations, error) { + calls.Add(1) + return nil, nil, nil, nil + }) + require.ErrorContains(t, err, "incompatible") + require.Zero(t, calls.Load()) + } +} diff --git a/pkg/crypto/field_metadata.go b/pkg/crypto/field_metadata.go new file mode 100644 index 000000000..1db2a4ac1 --- /dev/null +++ b/pkg/crypto/field_metadata.go @@ -0,0 +1,58 @@ +package crypto + +import ( + "fmt" + "regexp" + "strings" + + config "github.com/conductorone/baton-sdk/pb/c1/config/v1" +) + +const multipassTypePrefix = "multipass-type-" + +var fieldTagPattern = regexp.MustCompile(`^[a-zA-Z0-9_.-]+$`) +var fieldMIMEPattern = regexp.MustCompile(`^[^/]+/[^/]+$`) + +// FieldOutput describes one field's encoding and logical delivery type. +type FieldOutput struct { + Name string + ContentType string + LogicalType string + HasType bool +} + +// ResolveFieldOutput validates metadata and resolves the reserved type tag. +func ResolveFieldOutput(field *config.Field) (FieldOutput, error) { + if field == nil { + return FieldOutput{}, fmt.Errorf("field is nil") + } + result := FieldOutput{Name: field.GetName(), ContentType: field.GetContentType(), LogicalType: "generic"} + if result.ContentType != "" { + if len(result.ContentType) > 128 || !fieldMIMEPattern.MatchString(result.ContentType) { + return FieldOutput{}, fmt.Errorf("incompatible field content type: expected MIME type") + } + switch field.WhichField() { + case config.Field_StringField_case, config.Field_StringSliceField_case, config.Field_StringMapField_case: + default: + return FieldOutput{}, fmt.Errorf("field content type requires a string field") + } + } + if len(field.GetTags()) > 16 { + return FieldOutput{}, fmt.Errorf("field has too many tags") + } + seen := make(map[string]bool, len(field.GetTags())) + for _, tag := range field.GetTags() { + if len(tag) > 143 || !fieldTagPattern.MatchString(tag) || seen[tag] { + return FieldOutput{}, fmt.Errorf("invalid or duplicate field tag") + } + seen[tag] = true + if strings.HasPrefix(tag, multipassTypePrefix) { + value := strings.TrimPrefix(tag, multipassTypePrefix) + if result.HasType || !ValidSecretContentType(value) { + return FieldOutput{}, fmt.Errorf("invalid or ambiguous logical type tag") + } + result.LogicalType, result.HasType = value, true + } + } + return result, nil +} diff --git a/pkg/crypto/secret_content_type.go b/pkg/crypto/secret_content_type.go new file mode 100644 index 000000000..21a23802f --- /dev/null +++ b/pkg/crypto/secret_content_type.go @@ -0,0 +1,59 @@ +package crypto + +import ( + "bytes" + "encoding/json" + "io" + "regexp" +) + +var secretContentTypePattern = regexp.MustCompile(`^[a-z0-9]+([._-][a-z0-9]+)*$`) + +// ValidSecretContentType reports whether a type identifier can be declared. +func ValidSecretContentType(value string) bool { + return len(value) <= 128 && secretContentTypePattern.MatchString(value) +} + +// TypedOutputContextMatches checks the type-bearing recipient context. +func TypedOutputContextMatches(encoded []byte, contentType string) bool { + if len(encoded) == 0 || len(encoded) > 16*1024 { + return false + } + decoder := json.NewDecoder(bytes.NewReader(encoded)) + token, err := decoder.Token() + if err != nil || token != json.Delim('{') { + return false + } + fields := make(map[string]json.RawMessage) + for decoder.More() { + token, err := decoder.Token() + if err != nil { + return false + } + name, ok := token.(string) + if !ok { + return false + } + if _, duplicate := fields[name]; duplicate { + return false + } + var value json.RawMessage + if err := decoder.Decode(&value); err != nil { + return false + } + fields[name] = value + } + if token, err := decoder.Token(); err != nil || token != json.Delim('}') { + return false + } + if _, err := decoder.Token(); err != io.EOF { + return false + } + var purpose, actualType string + var version uint32 + if json.Unmarshal(fields["purpose"], &purpose) != nil || json.Unmarshal(fields["version"], &version) != nil || + json.Unmarshal(fields["content_type"], &actualType) != nil { + return false + } + return purpose == "c1/fk-credential-delivery" && version == 1 && actualType == contentType +} diff --git a/pkg/lambda/grpc/transport.go b/pkg/lambda/grpc/transport.go index 7da6f05d2..e2c4590c9 100644 --- a/pkg/lambda/grpc/transport.go +++ b/pkg/lambda/grpc/transport.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "errors" + "fmt" spb "google.golang.org/genproto/googleapis/rpc/status" "google.golang.org/grpc/codes" @@ -11,10 +12,11 @@ import ( "google.golang.org/grpc/status" "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/reflect/protoregistry" "google.golang.org/protobuf/types/known/anypb" pbtransport "github.com/conductorone/baton-sdk/pb/c1/transport/v1" - "google.golang.org/protobuf/reflect/protoregistry" ) const annotationsFieldName = "annotations" @@ -46,6 +48,10 @@ func unmarshalTransportJSON(b []byte, msg proto.Message) (bool, error) { return true, err } + if err := rejectLegacyFieldJSON(b, msg.ProtoReflect().Descriptor()); err != nil { + return false, err + } + unmarshalOptions := protojson.UnmarshalOptions{ DiscardUnknown: true, } @@ -353,3 +359,61 @@ func (f *Response) Headers() metadata.MD { func (f *Response) Trailers() metadata.MD { return UnmarshalMetadata(f.msg.GetTrailers()) } + +func rejectLegacyFieldJSON(raw json.RawMessage, descriptor protoreflect.MessageDescriptor) error { + var obj map[string]json.RawMessage + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 || trimmed[0] != '{' { + return nil + } + if err := json.Unmarshal(raw, &obj); err != nil { + return err + } + if descriptor.FullName() == "google.protobuf.Any" { + var typeURL string + if json.Unmarshal(obj["@type"], &typeURL) == nil { + if typ, err := protoregistry.GlobalTypes.FindMessageByURL(typeURL); err == nil { + if typ.Descriptor().FullName() == descriptor.FullName() { + return rejectLegacyFieldJSON(obj["value"], descriptor) + } + return rejectLegacyFieldJSON(raw, typ.Descriptor()) + } + } + return nil + } + if descriptor.FullName() == "c1.config.v1.Field" { + if _, ok := obj["secret_content_type"]; ok { + return fmt.Errorf("incompatible legacy Field secret_content_type") + } + if _, ok := obj["secretContentType"]; ok { + return fmt.Errorf("incompatible legacy Field secret_content_type") + } + } + fields := descriptor.Fields() + for i := 0; i < fields.Len(); i++ { + f := fields.Get(i) + if f.Message() == nil || f.IsMap() { + continue + } + v, ok := obj[f.JSONName()] + if !ok { + v, ok = obj[string(f.Name())] + } + if !ok { + continue + } + if f.IsList() { + var values []json.RawMessage + if json.Unmarshal(v, &values) == nil { + for _, item := range values { + if err := rejectLegacyFieldJSON(item, f.Message()); err != nil { + return err + } + } + } + } else if err := rejectLegacyFieldJSON(v, f.Message()); err != nil { + return err + } + } + return nil +} diff --git a/pkg/lambda/grpc/wire_test.go b/pkg/lambda/grpc/wire_test.go index f7ac9cf02..c612cfb98 100644 --- a/pkg/lambda/grpc/wire_test.go +++ b/pkg/lambda/grpc/wire_test.go @@ -260,3 +260,21 @@ func TestDecodeWireFrame_UnsupportedVersion(t *testing.T) { err := rt.UnmarshalJSON([]byte(`{"v": 3, "frame": "AA=="}`)) require.ErrorContains(t, err, "unsupported wire frame version 3") } + +func TestLegacyFieldJSONIsRefused(t *testing.T) { + for _, name := range []string{"secret_content_type", "secretContentType"} { + payload := []byte(`{"schema":{"name":"a","returnTypes":[{"name":"secret","` + name + `":"generic"}]}}`) + var msg v2.GetActionSchemaResponse + _, err := unmarshalTransportJSON(payload, &msg) + require.ErrorContains(t, err, "incompatible legacy Field") + } +} + +func TestLegacyFieldJSONAnyGuard(t *testing.T) { + var message anypb.Any + require.NoError(t, rejectLegacyFieldJSON([]byte(`{"@type":"type.googleapis.com/google.protobuf.Any"}`), message.ProtoReflect().Descriptor())) + nested := []byte(`{"@type":"type.googleapis.com/google.protobuf.Any","value":{"@type":"type.googleapis.com/c1.config.v1.Field","secretContentType":"generic"}}`) + require.ErrorContains(t, rejectLegacyFieldJSON(nested, message.ProtoReflect().Descriptor()), "incompatible legacy Field") + field := []byte(`{"@type":"type.googleapis.com/c1.config.v1.Field","secretContentType":"generic"}`) + require.ErrorContains(t, rejectLegacyFieldJSON(field, message.ProtoReflect().Descriptor()), "incompatible legacy Field") +} diff --git a/proto/c1/config/v1/config.proto b/proto/c1/config/v1/config.proto index 83ed697aa..7f6c6bc58 100644 --- a/proto/c1/config/v1/config.proto +++ b/proto/c1/config/v1/config.proto @@ -59,13 +59,16 @@ message Field { // through ActionHandlerWithSecrets; it is omitted from the public response // and returned as EncryptedData. bool is_secret = 7; - // Content type for secret action return fields; absent means generic. - // Action registration rejects it on arguments and non-secret returns. - // It has no configuration-field semantics; syntax validation still applies. - optional string secret_content_type = 8 [(validate.rules).string = { + string content_type = 8 [(validate.rules).string = { + ignore_empty: true min_len: 1 max_len: 128 - pattern: "^[a-z0-9]+([._-][a-z0-9]+)*$" + pattern: "^[^/]+/[^/]+$" + }]; + repeated string tags = 9 [(validate.rules).repeated = { + unique: true + items: {string: {min_len: 1 max_len: 143 pattern: "^[a-zA-Z0-9_.-]+$"}} + max_items: 16 }]; oneof field { From 1a072f736f7758c929fd7752968240863a2812f7 Mon Sep 17 00:00:00 2001 From: Brandon High <759848+highb@users.noreply.github.com> Date: Mon, 2 Nov 2026 23:33:31 +0000 Subject: [PATCH 14/16] Regenerate shared field metadata protobufs Co-authored-by: c1-squire-dev[bot] --- pb/c1/config/v1/config.pb.go | 61 ++++++------- pb/c1/config/v1/config.pb.validate.go | 109 +++++++++++++++++------ pb/c1/config/v1/config_protoopaque.pb.go | 86 ++++++++---------- 3 files changed, 149 insertions(+), 107 deletions(-) diff --git a/pb/c1/config/v1/config.pb.go b/pb/c1/config/v1/config.pb.go index ad7cff744..bdea96489 100644 --- a/pb/c1/config/v1/config.pb.go +++ b/pb/c1/config/v1/config.pb.go @@ -564,11 +564,9 @@ type Field struct { // action return types, the connector must return the value as PlaintextData // through ActionHandlerWithSecrets; it is omitted from the public response // and returned as EncryptedData. - IsSecret bool `protobuf:"varint,7,opt,name=is_secret,json=isSecret,proto3" json:"is_secret,omitempty"` - // Content type for secret action return fields; absent means generic. - // Action registration rejects it on arguments and non-secret returns. - // It has no configuration-field semantics; syntax validation still applies. - SecretContentType *string `protobuf:"bytes,8,opt,name=secret_content_type,json=secretContentType,proto3,oneof" json:"secret_content_type,omitempty"` + IsSecret bool `protobuf:"varint,7,opt,name=is_secret,json=isSecret,proto3" json:"is_secret,omitempty"` + ContentType string `protobuf:"bytes,8,opt,name=content_type,json=contentType,proto3" json:"content_type,omitempty"` + Tags []string `protobuf:"bytes,9,rep,name=tags,proto3" json:"tags,omitempty"` // Types that are valid to be assigned to Field: // // *Field_StringField @@ -661,13 +659,20 @@ func (x *Field) GetIsSecret() bool { return false } -func (x *Field) GetSecretContentType() string { - if x != nil && x.SecretContentType != nil { - return *x.SecretContentType +func (x *Field) GetContentType() string { + if x != nil { + return x.ContentType } return "" } +func (x *Field) GetTags() []string { + if x != nil { + return x.Tags + } + return nil +} + func (x *Field) GetField() isField_Field { if x != nil { return x.Field @@ -802,8 +807,12 @@ func (x *Field) SetIsSecret(v bool) { x.IsSecret = v } -func (x *Field) SetSecretContentType(v string) { - x.SecretContentType = &v +func (x *Field) SetContentType(v string) { + x.ContentType = v +} + +func (x *Field) SetTags(v []string) { + x.Tags = v } func (x *Field) SetStringField(v *StringField) { @@ -894,13 +903,6 @@ func (x *Field) SetGrantSliceField(v *GrantSliceField) { x.Field = &Field_GrantSliceField{v} } -func (x *Field) HasSecretContentType() bool { - if x == nil { - return false - } - return x.SecretContentType != nil -} - func (x *Field) HasField() bool { if x == nil { return false @@ -996,10 +998,6 @@ func (x *Field) HasGrantSliceField() bool { return ok } -func (x *Field) ClearSecretContentType() { - x.SecretContentType = nil -} - func (x *Field) ClearField() { x.Field = nil } @@ -1128,11 +1126,9 @@ type Field_builder struct { // action return types, the connector must return the value as PlaintextData // through ActionHandlerWithSecrets; it is omitted from the public response // and returned as EncryptedData. - IsSecret bool - // Content type for secret action return fields; absent means generic. - // Action registration rejects it on arguments and non-secret returns. - // It has no configuration-field semantics; syntax validation still applies. - SecretContentType *string + IsSecret bool + ContentType string + Tags []string // Fields of oneof Field: StringField *StringField IntField *IntField @@ -1160,7 +1156,8 @@ func (b0 Field_builder) Build() *Field { x.IsRequired = b.IsRequired x.IsOps = b.IsOps x.IsSecret = b.IsSecret - x.SecretContentType = b.SecretContentType + x.ContentType = b.ContentType + x.Tags = b.Tags if b.StringField != nil { x.Field = &Field_StringField{b.StringField} } @@ -2866,7 +2863,7 @@ const file_c1_config_v1_config_proto_rawDesc = "" + "\fdisplay_name\x18\x02 \x01(\tR\vdisplayName\x12\x1b\n" + "\thelp_text\x18\x03 \x01(\tR\bhelpText\x12\x16\n" + "\x06fields\x18\x04 \x03(\tR\x06fields\x12\x18\n" + - "\adefault\x18\x05 \x01(\bR\adefault\"\x94\t\n" + + "\adefault\x18\x05 \x01(\bR\adefault\"\x9a\t\n" + "\x05Field\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12!\n" + "\fdisplay_name\x18\x02 \x01(\tR\vdisplayName\x12 \n" + @@ -2875,8 +2872,9 @@ const file_c1_config_v1_config_proto_rawDesc = "" + "\vis_required\x18\x05 \x01(\bR\n" + "isRequired\x12\x15\n" + "\x06is_ops\x18\x06 \x01(\bR\x05isOps\x12\x1b\n" + - "\tis_secret\x18\a \x01(\bR\bisSecret\x12]\n" + - "\x13secret_content_type\x18\b \x01(\tB(\xfaB%r#\x10\x01\x18\x80\x012\x1c^[a-z0-9]+([._-][a-z0-9]+)*$H\x01R\x11secretContentType\x88\x01\x01\x12>\n" + + "\tis_secret\x18\a \x01(\bR\bisSecret\x12?\n" + + "\fcontent_type\x18\b \x01(\tB\x1c\xfaB\x19r\x17\x10\x01\x18\x80\x012\r^[^/]+/[^/]+$\xd0\x01\x01R\vcontentType\x12:\n" + + "\x04tags\x18\t \x03(\tB&\xfaB#\x92\x01 \x10\x10\x18\x01\"\x1ar\x18\x10\x01\x18\x8f\x012\x11^[a-zA-Z0-9_.-]+$R\x04tags\x12>\n" + "\fstring_field\x18d \x01(\v2\x19.c1.config.v1.StringFieldH\x00R\vstringField\x125\n" + "\tint_field\x18e \x01(\v2\x16.c1.config.v1.IntFieldH\x00R\bintField\x128\n" + "\n" + @@ -2889,8 +2887,7 @@ const file_c1_config_v1_config_proto_rawDesc = "" + "\x14resource_slice_field\x18l \x01(\v2 .c1.config.v1.ResourceSliceFieldH\x00R\x12resourceSliceField\x12]\n" + "\x17entitlement_slice_field\x18m \x01(\v2#.c1.config.v1.EntitlementSliceFieldH\x00R\x15entitlementSliceField\x12K\n" + "\x11grant_slice_field\x18n \x01(\v2\x1d.c1.config.v1.GrantSliceFieldH\x00R\x0fgrantSliceFieldB\a\n" + - "\x05fieldB\x16\n" + - "\x14_secret_content_type\"\x8a\x02\n" + + "\x05field\"\x8a\x02\n" + "\bResource\x129\n" + "\vresource_id\x18\x01 \x01(\v2\x18.c1.config.v1.ResourceIdR\n" + "resourceId\x12F\n" + diff --git a/pb/c1/config/v1/config.pb.validate.go b/pb/c1/config/v1/config.pb.validate.go index 9eb6a51db..b650d42eb 100644 --- a/pb/c1/config/v1/config.pb.validate.go +++ b/pb/c1/config/v1/config.pb.validate.go @@ -500,6 +500,85 @@ func (m *Field) validate(all bool) error { // no validation rules for IsSecret + if m.GetContentType() != "" { + + if l := utf8.RuneCountInString(m.GetContentType()); l < 1 || l > 128 { + err := FieldValidationError{ + field: "ContentType", + reason: "value length must be between 1 and 128 runes, inclusive", + } + if !all { + return err + } + errors = append(errors, err) + } + + if !_Field_ContentType_Pattern.MatchString(m.GetContentType()) { + err := FieldValidationError{ + field: "ContentType", + reason: "value does not match regex pattern \"^[^/]+/[^/]+$\"", + } + if !all { + return err + } + errors = append(errors, err) + } + + } + + if len(m.GetTags()) > 16 { + err := FieldValidationError{ + field: "Tags", + reason: "value must contain no more than 16 item(s)", + } + if !all { + return err + } + errors = append(errors, err) + } + + _Field_Tags_Unique := make(map[string]struct{}, len(m.GetTags())) + + for idx, item := range m.GetTags() { + _, _ = idx, item + + if _, exists := _Field_Tags_Unique[item]; exists { + err := FieldValidationError{ + field: fmt.Sprintf("Tags[%v]", idx), + reason: "repeated value must contain unique items", + } + if !all { + return err + } + errors = append(errors, err) + } else { + _Field_Tags_Unique[item] = struct{}{} + } + + if l := utf8.RuneCountInString(item); l < 1 || l > 143 { + err := FieldValidationError{ + field: fmt.Sprintf("Tags[%v]", idx), + reason: "value length must be between 1 and 143 runes, inclusive", + } + if !all { + return err + } + errors = append(errors, err) + } + + if !_Field_Tags_Pattern.MatchString(item) { + err := FieldValidationError{ + field: fmt.Sprintf("Tags[%v]", idx), + reason: "value does not match regex pattern \"^[a-zA-Z0-9_.-]+$\"", + } + if !all { + return err + } + errors = append(errors, err) + } + + } + switch v := m.Field.(type) { case *Field_StringField: if v == nil { @@ -956,32 +1035,6 @@ func (m *Field) validate(all bool) error { _ = v // ensures v is used } - if m.SecretContentType != nil { - - if l := utf8.RuneCountInString(m.GetSecretContentType()); l < 1 || l > 128 { - err := FieldValidationError{ - field: "SecretContentType", - reason: "value length must be between 1 and 128 runes, inclusive", - } - if !all { - return err - } - errors = append(errors, err) - } - - if !_Field_SecretContentType_Pattern.MatchString(m.GetSecretContentType()) { - err := FieldValidationError{ - field: "SecretContentType", - reason: "value does not match regex pattern \"^[a-z0-9]+([._-][a-z0-9]+)*$\"", - } - if !all { - return err - } - errors = append(errors, err) - } - - } - if len(errors) > 0 { return FieldMultiError(errors) } @@ -1059,7 +1112,9 @@ var _ interface { ErrorName() string } = FieldValidationError{} -var _Field_SecretContentType_Pattern = regexp.MustCompile("^[a-z0-9]+([._-][a-z0-9]+)*$") +var _Field_ContentType_Pattern = regexp.MustCompile("^[^/]+/[^/]+$") + +var _Field_Tags_Pattern = regexp.MustCompile("^[a-zA-Z0-9_.-]+$") // Validate checks the field values on Resource with the rules defined in the // proto definition for this message. If any rules are violated, the first diff --git a/pb/c1/config/v1/config_protoopaque.pb.go b/pb/c1/config/v1/config_protoopaque.pb.go index dd6e39fee..9af05abcd 100644 --- a/pb/c1/config/v1/config_protoopaque.pb.go +++ b/pb/c1/config/v1/config_protoopaque.pb.go @@ -559,20 +559,19 @@ func (b0 FieldGroup_builder) Build() *FieldGroup { } type Field struct { - state protoimpl.MessageState `protogen:"opaque.v1"` - xxx_hidden_Name string `protobuf:"bytes,1,opt,name=name,proto3"` - xxx_hidden_DisplayName string `protobuf:"bytes,2,opt,name=display_name,json=displayName,proto3"` - xxx_hidden_Description string `protobuf:"bytes,3,opt,name=description,proto3"` - xxx_hidden_Placeholder string `protobuf:"bytes,4,opt,name=placeholder,proto3"` - xxx_hidden_IsRequired bool `protobuf:"varint,5,opt,name=is_required,json=isRequired,proto3"` - xxx_hidden_IsOps bool `protobuf:"varint,6,opt,name=is_ops,json=isOps,proto3"` - xxx_hidden_IsSecret bool `protobuf:"varint,7,opt,name=is_secret,json=isSecret,proto3"` - xxx_hidden_SecretContentType *string `protobuf:"bytes,8,opt,name=secret_content_type,json=secretContentType,proto3,oneof"` - xxx_hidden_Field isField_Field `protobuf_oneof:"field"` - XXX_raceDetectHookData protoimpl.RaceDetectHookData - XXX_presence [1]uint32 - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"opaque.v1"` + xxx_hidden_Name string `protobuf:"bytes,1,opt,name=name,proto3"` + xxx_hidden_DisplayName string `protobuf:"bytes,2,opt,name=display_name,json=displayName,proto3"` + xxx_hidden_Description string `protobuf:"bytes,3,opt,name=description,proto3"` + xxx_hidden_Placeholder string `protobuf:"bytes,4,opt,name=placeholder,proto3"` + xxx_hidden_IsRequired bool `protobuf:"varint,5,opt,name=is_required,json=isRequired,proto3"` + xxx_hidden_IsOps bool `protobuf:"varint,6,opt,name=is_ops,json=isOps,proto3"` + xxx_hidden_IsSecret bool `protobuf:"varint,7,opt,name=is_secret,json=isSecret,proto3"` + xxx_hidden_ContentType string `protobuf:"bytes,8,opt,name=content_type,json=contentType,proto3"` + xxx_hidden_Tags []string `protobuf:"bytes,9,rep,name=tags,proto3"` + xxx_hidden_Field isField_Field `protobuf_oneof:"field"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *Field) Reset() { @@ -649,16 +648,20 @@ func (x *Field) GetIsSecret() bool { return false } -func (x *Field) GetSecretContentType() string { +func (x *Field) GetContentType() string { if x != nil { - if x.xxx_hidden_SecretContentType != nil { - return *x.xxx_hidden_SecretContentType - } - return "" + return x.xxx_hidden_ContentType } return "" } +func (x *Field) GetTags() []string { + if x != nil { + return x.xxx_hidden_Tags + } + return nil +} + func (x *Field) GetStringField() *StringField { if x != nil { if x, ok := x.xxx_hidden_Field.(*field_StringField); ok { @@ -786,9 +789,12 @@ func (x *Field) SetIsSecret(v bool) { x.xxx_hidden_IsSecret = v } -func (x *Field) SetSecretContentType(v string) { - x.xxx_hidden_SecretContentType = &v - protoimpl.X.SetPresent(&(x.XXX_presence[0]), 7, 9) +func (x *Field) SetContentType(v string) { + x.xxx_hidden_ContentType = v +} + +func (x *Field) SetTags(v []string) { + x.xxx_hidden_Tags = v } func (x *Field) SetStringField(v *StringField) { @@ -879,13 +885,6 @@ func (x *Field) SetGrantSliceField(v *GrantSliceField) { x.xxx_hidden_Field = &field_GrantSliceField{v} } -func (x *Field) HasSecretContentType() bool { - if x == nil { - return false - } - return protoimpl.X.Present(&(x.XXX_presence[0]), 7) -} - func (x *Field) HasField() bool { if x == nil { return false @@ -981,11 +980,6 @@ func (x *Field) HasGrantSliceField() bool { return ok } -func (x *Field) ClearSecretContentType() { - protoimpl.X.ClearPresent(&(x.XXX_presence[0]), 7) - x.xxx_hidden_SecretContentType = nil -} - func (x *Field) ClearField() { x.xxx_hidden_Field = nil } @@ -1114,11 +1108,9 @@ type Field_builder struct { // action return types, the connector must return the value as PlaintextData // through ActionHandlerWithSecrets; it is omitted from the public response // and returned as EncryptedData. - IsSecret bool - // Content type for secret action return fields; absent means generic. - // Action registration rejects it on arguments and non-secret returns. - // It has no configuration-field semantics; syntax validation still applies. - SecretContentType *string + IsSecret bool + ContentType string + Tags []string // Fields of oneof xxx_hidden_Field: StringField *StringField IntField *IntField @@ -1146,10 +1138,8 @@ func (b0 Field_builder) Build() *Field { x.xxx_hidden_IsRequired = b.IsRequired x.xxx_hidden_IsOps = b.IsOps x.xxx_hidden_IsSecret = b.IsSecret - if b.SecretContentType != nil { - protoimpl.X.SetPresentNonAtomic(&(x.XXX_presence[0]), 7, 9) - x.xxx_hidden_SecretContentType = b.SecretContentType - } + x.xxx_hidden_ContentType = b.ContentType + x.xxx_hidden_Tags = b.Tags if b.StringField != nil { x.xxx_hidden_Field = &field_StringField{b.StringField} } @@ -2865,7 +2855,7 @@ const file_c1_config_v1_config_proto_rawDesc = "" + "\fdisplay_name\x18\x02 \x01(\tR\vdisplayName\x12\x1b\n" + "\thelp_text\x18\x03 \x01(\tR\bhelpText\x12\x16\n" + "\x06fields\x18\x04 \x03(\tR\x06fields\x12\x18\n" + - "\adefault\x18\x05 \x01(\bR\adefault\"\x94\t\n" + + "\adefault\x18\x05 \x01(\bR\adefault\"\x9a\t\n" + "\x05Field\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12!\n" + "\fdisplay_name\x18\x02 \x01(\tR\vdisplayName\x12 \n" + @@ -2874,8 +2864,9 @@ const file_c1_config_v1_config_proto_rawDesc = "" + "\vis_required\x18\x05 \x01(\bR\n" + "isRequired\x12\x15\n" + "\x06is_ops\x18\x06 \x01(\bR\x05isOps\x12\x1b\n" + - "\tis_secret\x18\a \x01(\bR\bisSecret\x12]\n" + - "\x13secret_content_type\x18\b \x01(\tB(\xfaB%r#\x10\x01\x18\x80\x012\x1c^[a-z0-9]+([._-][a-z0-9]+)*$H\x01R\x11secretContentType\x88\x01\x01\x12>\n" + + "\tis_secret\x18\a \x01(\bR\bisSecret\x12?\n" + + "\fcontent_type\x18\b \x01(\tB\x1c\xfaB\x19r\x17\x10\x01\x18\x80\x012\r^[^/]+/[^/]+$\xd0\x01\x01R\vcontentType\x12:\n" + + "\x04tags\x18\t \x03(\tB&\xfaB#\x92\x01 \x10\x10\x18\x01\"\x1ar\x18\x10\x01\x18\x8f\x012\x11^[a-zA-Z0-9_.-]+$R\x04tags\x12>\n" + "\fstring_field\x18d \x01(\v2\x19.c1.config.v1.StringFieldH\x00R\vstringField\x125\n" + "\tint_field\x18e \x01(\v2\x16.c1.config.v1.IntFieldH\x00R\bintField\x128\n" + "\n" + @@ -2888,8 +2879,7 @@ const file_c1_config_v1_config_proto_rawDesc = "" + "\x14resource_slice_field\x18l \x01(\v2 .c1.config.v1.ResourceSliceFieldH\x00R\x12resourceSliceField\x12]\n" + "\x17entitlement_slice_field\x18m \x01(\v2#.c1.config.v1.EntitlementSliceFieldH\x00R\x15entitlementSliceField\x12K\n" + "\x11grant_slice_field\x18n \x01(\v2\x1d.c1.config.v1.GrantSliceFieldH\x00R\x0fgrantSliceFieldB\a\n" + - "\x05fieldB\x16\n" + - "\x14_secret_content_type\"\x8a\x02\n" + + "\x05field\"\x8a\x02\n" + "\bResource\x129\n" + "\vresource_id\x18\x01 \x01(\v2\x18.c1.config.v1.ResourceIdR\n" + "resourceId\x12F\n" + From 5bae0e6c80264471ca102ee413c1b77578a65088 Mon Sep 17 00:00:00 2001 From: Brandon High <759848+highb@users.noreply.github.com> Date: Mon, 2 Nov 2026 23:50:37 +0000 Subject: [PATCH 15/16] Format shared field metadata proto Co-authored-by: c1-squire-dev[bot] --- proto/c1/config/v1/config.proto | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/proto/c1/config/v1/config.proto b/proto/c1/config/v1/config.proto index 7f6c6bc58..6ccaedc74 100644 --- a/proto/c1/config/v1/config.proto +++ b/proto/c1/config/v1/config.proto @@ -67,7 +67,13 @@ message Field { }]; repeated string tags = 9 [(validate.rules).repeated = { unique: true - items: {string: {min_len: 1 max_len: 143 pattern: "^[a-zA-Z0-9_.-]+$"}} + items: { + string: { + min_len: 1 + max_len: 143 + pattern: "^[a-zA-Z0-9_.-]+$" + } + } max_items: 16 }]; From 69b9808260b096ff3cb549328dc557857d6987c3 Mon Sep 17 00:00:00 2001 From: Brandon High <759848+highb@users.noreply.github.com> Date: Tue, 3 Nov 2026 00:14:55 +0000 Subject: [PATCH 16/16] Regenerate shared field metadata comments Co-authored-by: c1-squire-dev[bot] --- pb/c1/config/v1/config.pb.go | 6 ++++-- pb/c1/config/v1/config_protoopaque.pb.go | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/pb/c1/config/v1/config.pb.go b/pb/c1/config/v1/config.pb.go index bdea96489..82ceb721b 100644 --- a/pb/c1/config/v1/config.pb.go +++ b/pb/c1/config/v1/config.pb.go @@ -564,7 +564,8 @@ type Field struct { // action return types, the connector must return the value as PlaintextData // through ActionHandlerWithSecrets; it is omitted from the public response // and returned as EncryptedData. - IsSecret bool `protobuf:"varint,7,opt,name=is_secret,json=isSecret,proto3" json:"is_secret,omitempty"` + IsSecret bool `protobuf:"varint,7,opt,name=is_secret,json=isSecret,proto3" json:"is_secret,omitempty"` + // Optional: the content type of the field. Only valid for StringField, StringSliceField, & StringMapField. ContentType string `protobuf:"bytes,8,opt,name=content_type,json=contentType,proto3" json:"content_type,omitempty"` Tags []string `protobuf:"bytes,9,rep,name=tags,proto3" json:"tags,omitempty"` // Types that are valid to be assigned to Field: @@ -1126,7 +1127,8 @@ type Field_builder struct { // action return types, the connector must return the value as PlaintextData // through ActionHandlerWithSecrets; it is omitted from the public response // and returned as EncryptedData. - IsSecret bool + IsSecret bool + // Optional: the content type of the field. Only valid for StringField, StringSliceField, & StringMapField. ContentType string Tags []string // Fields of oneof Field: diff --git a/pb/c1/config/v1/config_protoopaque.pb.go b/pb/c1/config/v1/config_protoopaque.pb.go index 9af05abcd..2b1404297 100644 --- a/pb/c1/config/v1/config_protoopaque.pb.go +++ b/pb/c1/config/v1/config_protoopaque.pb.go @@ -1108,7 +1108,8 @@ type Field_builder struct { // action return types, the connector must return the value as PlaintextData // through ActionHandlerWithSecrets; it is omitted from the public response // and returned as EncryptedData. - IsSecret bool + IsSecret bool + // Optional: the content type of the field. Only valid for StringField, StringSliceField, & StringMapField. ContentType string Tags []string // Fields of oneof xxx_hidden_Field: