From bf00fc0579dc76e4ed59f237f04f027f55a2f267 Mon Sep 17 00:00:00 2001 From: Paul Querna Date: Fri, 5 Jun 2026 14:39:26 +0000 Subject: [PATCH 1/2] feat: express NHI + AI-agent ontology in the config schema Lets a config author declare the non-human-identity ontology and AI agents directly in the YAML grammar; the engine emits the matching baton-sdk traits. Mirrors the NHI parity baton-axiomatic reached (PRs #145-#148), expressed through baton-sql's config grammar instead of axiomatic's TS author surface. - K1 secret/credential: map.traits.secret {credential_type, credential_detail, expires_at, last_used_at} -> SecretTrait (TRAIT_SECRET). - K2 service/system account: map.traits.user.account_type (already supported) -> UserTrait.account_type; covered with a test. - K3 non-human identity: map.non_human_identity {nhi_type, nhi_detail} -> NonHumanIdentityTrait, kind-agnostic (sibling of traits; co-exists with any or no primary trait). - AI agent: map.traits.agent {status, identity_resource_type, identity_resource_id, profile} -> AgentTrait (TRAIT_AGENT). Additive and gracefully degrading: every field is optional, unknown enum strings warn and fall back to UNSPECIFIED, existing configs are unchanged. baton-sdk stays at v0.12.4 (already supersedes the v0.11.1 that introduced these traits). Adds nhi_test.go and examples/nhi-example.yml. Co-authored-by: c1-squire-dev[bot] --- examples/nhi-example.yml | 118 ++++++++++++++++++ pkg/bsql/config.go | 62 ++++++++++ pkg/bsql/nhi_test.go | 242 +++++++++++++++++++++++++++++++++++++ pkg/bsql/resource_types.go | 8 ++ pkg/bsql/resources.go | 222 ++++++++++++++++++++++++++++++++++ pkg/bsql/sql_syncer.go | 10 +- 6 files changed, 658 insertions(+), 4 deletions(-) create mode 100644 examples/nhi-example.yml create mode 100644 pkg/bsql/nhi_test.go diff --git a/examples/nhi-example.yml b/examples/nhi-example.yml new file mode 100644 index 00000000..05011344 --- /dev/null +++ b/examples/nhi-example.yml @@ -0,0 +1,118 @@ +--- +# Non-Human Identity (NHI) + AI-agent reference configuration +# =========================================================== +# Demonstrates how a baton-sql config author declares the NHI ontology and AI +# agents so the engine emits the corresponding baton-sdk traits: +# +# K1 secret / credential -> map.traits.secret (SecretTrait) +# K2 service / system account -> map.traits.user.account_type (UserTrait.account_type) +# K3 non-human identity -> map.non_human_identity (NonHumanIdentityTrait, kind-agnostic) +# AI agent -> map.traits.agent (AgentTrait) +# +# Every field below is optional and additive: configs that omit them sync +# exactly as before. Column references and CEL expressions resolve against each +# query row, identical to the other examples. + +app_name: NHI Reference Application + +connect: + scheme: "postgres" + host: "${DB_HOST}" + port: "5432" + database: "${DB_NAME}" + user: "${DB_USER}" + password: "${DB_PASS}" + +resource_types: + # K2 — service / system accounts. + # account_type maps to UserTrait.account_type: service | system | human (default). + service_account: + name: "Service Account" + description: "Machine accounts; classified service or system." + list: + query: | + SELECT id, username, kind FROM accounts + WHERE id > ? ORDER BY id ASC LIMIT ? + map: + id: ".id" + display_name: ".username" + traits: + user: + # "service" -> ACCOUNT_TYPE_SERVICE, "system" -> ACCOUNT_TYPE_SYSTEM + account_type: ".kind" + login: ".username" + pagination: + strategy: "cursor" + primary_key: "id" + + # K1 — secret / credential resources. + api_token: + name: "API Token" + description: "Long-lived API tokens issued by the platform." + list: + query: | + SELECT id, name, expires_at, last_used_at FROM api_tokens + WHERE id > ? ORDER BY id ASC LIMIT ? + map: + id: ".id" + display_name: ".name" + traits: + secret: + # static_secret | asymmetric_key | certificate + credential_type: "'static_secret'" + # free-form "." descriptor + credential_detail: "'postgres.api_token'" + expires_at: ".expires_at" + last_used_at: ".last_used_at" + pagination: + strategy: "cursor" + primary_key: "id" + + # K3 — non-human identity, attached alongside a primary (app) trait. + # NHI is kind-agnostic: non_human_identity is a sibling of traits, so the + # resource is both an app AND a non-human identity. + iam_role: + name: "IAM Role" + description: "Assumable roles modeled as app registrations / assumable roles." + list: + query: | + SELECT id, name, arn FROM iam_roles + WHERE id > ? ORDER BY id ASC LIMIT ? + map: + id: ".id" + display_name: ".name" + traits: + app: + profile: + arn: ".arn" + non_human_identity: + # app_registration | assumable_role | managed_identity + nhi_type: "'assumable_role'" + nhi_detail: "'aws.iam_role'" + pagination: + strategy: "cursor" + primary_key: "id" + + # AI agent — autonomous non-human actor that authenticates as an identity. + ai_agent: + name: "AI Agent" + description: "AI agents registered in the platform." + list: + query: | + SELECT id, name, state, identity_id, model FROM ai_agents + WHERE id > ? ORDER BY id ASC LIMIT ? + map: + id: ".id" + display_name: ".name" + traits: + agent: + # ready (active, enabled) | disabled (inactive) | deleted + status: ".state" + # the identity the agent authenticates as (both fields required to set the ref) + identity_resource_type: "'service_account'" + identity_resource_id: ".identity_id" + profile: + model: ".model" + pagination: + strategy: "cursor" + primary_key: "id" diff --git a/pkg/bsql/config.go b/pkg/bsql/config.go index da26540c..e60f346b 100644 --- a/pkg/bsql/config.go +++ b/pkg/bsql/config.go @@ -160,10 +160,28 @@ type ResourceMapping struct { // Traits defines specific attribute mappings for various resource subtypes (e.g., user, role). Traits *Traits `yaml:"traits" json:"traits"` + // NonHumanIdentity marks the resource as a non-human identity (K3). It is + // kind-agnostic: it attaches a NonHumanIdentityTrait annotation alongside + // whatever primary trait (if any) the resource carries, so it lives here as + // a sibling of Traits rather than inside it. + NonHumanIdentity *NonHumanIdentityMapping `yaml:"non_human_identity,omitempty" json:"non_human_identity,omitempty"` + // Annotations includes additional metadata such as entitlement immutability and external links. Annotations *Annotations `yaml:"annotations" json:"annotations"` } +// NonHumanIdentityMapping declares that a resource is a non-human identity (K3). +// Both fields are CEL expressions evaluated against the query row. +type NonHumanIdentityMapping struct { + // NhiType is the kind of non-human identity. + // Supported values: app_registration, assumable_role, managed_identity. + NhiType string `yaml:"nhi_type" json:"nhi_type"` + + // NhiDetail is a free-form descriptor of the identity, conventionally + // "." (e.g. "aws.iam_role"). + NhiDetail string `yaml:"nhi_detail,omitempty" json:"nhi_detail,omitempty"` +} + // Annotations holds extra metadata for resource or grant mappings. type Annotations struct { // EntitlementImmutable provides settings to mark an entitlement as immutable (e.g., cannot be revoked). @@ -186,6 +204,50 @@ type Traits struct { // User contains trait mappings for user resources. User *UserTraitMapping `yaml:"user" json:"user"` + + // Secret contains trait mappings for secret/credential resources (K1). + Secret *SecretTraitMapping `yaml:"secret,omitempty" json:"secret,omitempty"` + + // Agent contains trait mappings for AI-agent resources. + Agent *AgentTraitMapping `yaml:"agent,omitempty" json:"agent,omitempty"` +} + +// SecretTraitMapping defines attribute mappings for secret/credential resources (K1). +// All fields are CEL expressions evaluated against the query row. +type SecretTraitMapping struct { + // CredentialType classifies the secret. + // Supported values: static_secret, asymmetric_key, certificate. + CredentialType string `yaml:"credential_type" json:"credential_type"` + + // CredentialDetail is a free-form descriptor of the credential, + // conventionally "." (e.g. "postgres.api_token"). + CredentialDetail string `yaml:"credential_detail,omitempty" json:"credential_detail,omitempty"` + + // ExpiresAt records when the credential expires (parsed using the DB engine's time format). + ExpiresAt string `yaml:"expires_at,omitempty" json:"expires_at,omitempty"` + + // LastUsedAt records when the credential was last used. + LastUsedAt string `yaml:"last_used_at,omitempty" json:"last_used_at,omitempty"` +} + +// AgentTraitMapping defines attribute mappings for AI-agent resources. +// String fields are CEL expressions evaluated against the query row. +type AgentTraitMapping struct { + // Status is the agent's lifecycle status. + // Supported values: ready (active, enabled), disabled (inactive), deleted. + Status string `yaml:"status,omitempty" json:"status,omitempty"` + + // IdentityResourceType is the resource type of the identity the agent + // authenticates as. Required (together with IdentityResourceID) to set the + // agent's identity reference. + IdentityResourceType string `yaml:"identity_resource_type,omitempty" json:"identity_resource_type,omitempty"` + + // IdentityResourceID is the resource id of the identity the agent + // authenticates as. + IdentityResourceID string `yaml:"identity_resource_id,omitempty" json:"identity_resource_id,omitempty"` + + // Profile is a set of key-value pairs representing agent profile attributes. + Profile map[string]string `yaml:"profile,omitempty" json:"profile,omitempty"` } // UserTraitMapping defines attribute mappings specifically for user resources. diff --git a/pkg/bsql/nhi_test.go b/pkg/bsql/nhi_test.go new file mode 100644 index 00000000..648e7dc3 --- /dev/null +++ b/pkg/bsql/nhi_test.go @@ -0,0 +1,242 @@ +package bsql + +import ( + "testing" + + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/annotations" + sdkResource "github.com/conductorone/baton-sdk/pkg/types/resource" + "github.com/stretchr/testify/require" + + "github.com/conductorone/baton-sql/pkg/bcel" + "google.golang.org/protobuf/proto" +) + +// pickAnno reports whether the given annotation message is present on the resource. +func pickAnno(t *testing.T, r *v2.Resource, m proto.Message) bool { + t.Helper() + annos := annotations.Annotations(r.Annotations) + ok, err := annos.Pick(m) + require.NoError(t, err) + return ok +} + +// newTraitTestSyncer builds a SQLSyncer wired with a real CEL env and the given +// resource-type config, suitable for exercising the row -> resource mapping. +func newTraitTestSyncer(t *testing.T, rt ResourceType) *SQLSyncer { + t.Helper() + env, err := bcel.NewEnv(t.Context()) + require.NoError(t, err) + return &SQLSyncer{ + resourceType: &v2.ResourceType{Id: "test"}, + config: rt, + env: env, + } +} + +func baseMapping(traits *Traits, nhi *NonHumanIdentityMapping) ResourceType { + return ResourceType{ + Name: "Test", + List: &ListQuery{ + Query: "SELECT 1", + Map: &ResourceMapping{ + Id: ".id", + DisplayName: ".name", + Traits: traits, + NonHumanIdentity: nhi, + }, + }, + } +} + +// K1 — a secret resource emits a SecretTrait with the mapped credential type/detail. +func TestMapResource_SecretTrait(t *testing.T) { + ctx := t.Context() + s := newTraitTestSyncer(t, baseMapping(&Traits{ + Secret: &SecretTraitMapping{ + CredentialType: "'static_secret'", + CredentialDetail: "'postgres.api_token'", + }, + }, nil)) + + r, err := s.mapResource(ctx, map[string]any{"id": "tok-1", "name": "API token"}) + require.NoError(t, err) + + st := &v2.SecretTrait{} + require.True(t, pickAnno(t, r, st), "expected a SecretTrait annotation") + require.Equal(t, v2.SecretTrait_CREDENTIAL_TYPE_STATIC_SECRET, st.GetCredentialType()) + require.Equal(t, "postgres.api_token", st.GetCredentialDetail()) +} + +func TestMapResource_SecretTrait_CredentialTypes(t *testing.T) { + ctx := t.Context() + cases := map[string]v2.SecretTrait_CredentialType{ + "'static_secret'": v2.SecretTrait_CREDENTIAL_TYPE_STATIC_SECRET, + "'asymmetric_key'": v2.SecretTrait_CREDENTIAL_TYPE_ASYMMETRIC_KEY, + "'certificate'": v2.SecretTrait_CREDENTIAL_TYPE_CERTIFICATE, + "'bogus'": v2.SecretTrait_CREDENTIAL_TYPE_UNSPECIFIED, + } + for expr, want := range cases { + s := newTraitTestSyncer(t, baseMapping(&Traits{ + Secret: &SecretTraitMapping{CredentialType: expr}, + }, nil)) + r, err := s.mapResource(ctx, map[string]any{"id": "x", "name": "x"}) + require.NoError(t, err) + st := &v2.SecretTrait{} + require.True(t, pickAnno(t, r, st)) + require.Equal(t, want, st.GetCredentialType(), "expr %s", expr) + } +} + +// K2 — account_type mapping (already supported) emits SERVICE/SYSTEM. +func TestMapResource_AccountType(t *testing.T) { + ctx := t.Context() + cases := map[string]v2.UserTrait_AccountType{ + "'service'": v2.UserTrait_ACCOUNT_TYPE_SERVICE, + "'system'": v2.UserTrait_ACCOUNT_TYPE_SYSTEM, + "'human'": v2.UserTrait_ACCOUNT_TYPE_HUMAN, + } + for expr, want := range cases { + s := newTraitTestSyncer(t, baseMapping(&Traits{ + User: &UserTraitMapping{AccountType: expr}, + }, nil)) + r, err := s.mapResource(ctx, map[string]any{"id": "svc", "name": "svc"}) + require.NoError(t, err) + ut, err := sdkResource.GetUserTrait(r) + require.NoError(t, err) + require.Equal(t, want, ut.GetAccountType(), "expr %s", expr) + } +} + +// K3 — NHI is kind-agnostic: it co-exists with a primary (app) trait. +func TestMapResource_NonHumanIdentity_WithApp(t *testing.T) { + ctx := t.Context() + s := newTraitTestSyncer(t, baseMapping( + &Traits{App: &AppTraitMapping{}}, + &NonHumanIdentityMapping{ + NhiType: "'assumable_role'", + NhiDetail: "'aws.iam_role'", + }, + )) + + r, err := s.mapResource(ctx, map[string]any{"id": "role-1", "name": "deploy-role"}) + require.NoError(t, err) + + // primary app trait still present + _, err = sdkResource.GetAppTrait(r) + require.NoError(t, err, "expected the primary AppTrait to remain") + + nhi, err := sdkResource.GetNonHumanIdentityTrait(r) + require.NoError(t, err) + require.Equal(t, v2.NonHumanIdentityTrait_NHI_TYPE_ASSUMABLE_ROLE, nhi.GetNhiType()) + require.Equal(t, "aws.iam_role", nhi.GetNhiDetail()) +} + +// K3 — NHI may stand alone with no primary trait. +func TestMapResource_NonHumanIdentity_Standalone(t *testing.T) { + ctx := t.Context() + s := newTraitTestSyncer(t, baseMapping(nil, &NonHumanIdentityMapping{ + NhiType: "'managed_identity'", + NhiDetail: "'azure.managed_identity'", + })) + + r, err := s.mapResource(ctx, map[string]any{"id": "mi-1", "name": "vm-identity"}) + require.NoError(t, err) + + nhi, err := sdkResource.GetNonHumanIdentityTrait(r) + require.NoError(t, err) + require.Equal(t, v2.NonHumanIdentityTrait_NHI_TYPE_MANAGED_IDENTITY, nhi.GetNhiType()) +} + +// Agent — emits an AgentTrait with status, identity ref, and profile. +func TestMapResource_AgentTrait(t *testing.T) { + ctx := t.Context() + s := newTraitTestSyncer(t, baseMapping(&Traits{ + Agent: &AgentTraitMapping{ + Status: "'ready'", + IdentityResourceType: "'user'", + IdentityResourceID: ".identity_id", + Profile: map[string]string{"model": ".model"}, + }, + }, nil)) + + r, err := s.mapResource(ctx, map[string]any{ + "id": "agent-1", + "name": "support-bot", + "identity_id": "svc-acct-1", + "model": "claude-opus", + }) + require.NoError(t, err) + + at, err := sdkResource.GetAgentTrait(r) + require.NoError(t, err) + require.Equal(t, v2.AgentTrait_AGENT_STATUS_READY, at.GetStatus()) + require.NotNil(t, at.GetIdentityResourceId()) + require.Equal(t, "user", at.GetIdentityResourceId().GetResourceType()) + require.Equal(t, "svc-acct-1", at.GetIdentityResourceId().GetResource()) + require.Equal(t, "claude-opus", at.GetProfile().GetFields()["model"].GetStringValue()) +} + +// Graceful degradation — a plain user resource emits only a UserTrait, no +// secret/NHI/agent annotations. +func TestMapResource_NoNHIByDefault(t *testing.T) { + ctx := t.Context() + s := newTraitTestSyncer(t, baseMapping(&Traits{ + User: &UserTraitMapping{}, + }, nil)) + + r, err := s.mapResource(ctx, map[string]any{"id": "u1", "name": "Jane"}) + require.NoError(t, err) + + _, err = sdkResource.GetUserTrait(r) + require.NoError(t, err) + + require.False(t, pickAnno(t, r, &v2.SecretTrait{}), "no SecretTrait expected") + require.False(t, pickAnno(t, r, &v2.NonHumanIdentityTrait{}), "no NonHumanIdentityTrait expected") + require.False(t, pickAnno(t, r, &v2.AgentTrait{}), "no AgentTrait expected") +} + +// The shipped nhi-example.yml parses and advertises the expected traits. +func TestNHIExampleConfig(t *testing.T) { + ctx := t.Context() + raw := loadExampleConfig(t, "nhi-example") + c, err := Parse([]byte(raw)) + require.NoError(t, err) + + want := map[string][]v2.ResourceType_Trait{ + "service_account": {v2.ResourceType_TRAIT_USER}, + "api_token": {v2.ResourceType_TRAIT_SECRET}, + "iam_role": {v2.ResourceType_TRAIT_APP}, + "ai_agent": {v2.ResourceType_TRAIT_AGENT}, + } + for rtID, wantTraits := range want { + got, err := c.extractTraits(rtID) + require.NoError(t, err, rtID) + require.Equal(t, wantTraits, got, rtID) + } + + // iam_role declares a kind-agnostic NHI mapping alongside its app trait. + require.NotNil(t, c.ResourceTypes["iam_role"].List.Map.NonHumanIdentity) + + rts, err := c.GetResourceTypes(ctx) + require.NoError(t, err) + require.Len(t, rts, 4) +} + +// Resource-type advertisement includes TRAIT_SECRET / TRAIT_AGENT. +func TestExtractTraits_SecretAndAgent(t *testing.T) { + ctx := t.Context() + _ = ctx + cfg := Config{ResourceTypes: map[string]ResourceType{ + "secret": baseMapping(&Traits{Secret: &SecretTraitMapping{CredentialType: "'static_secret'"}}, nil), + "agent": baseMapping(&Traits{Agent: &AgentTraitMapping{Status: "'ready'"}}, nil), + }} + + secretTraits, err := cfg.extractTraits("secret") + require.NoError(t, err) + require.Equal(t, []v2.ResourceType_Trait{v2.ResourceType_TRAIT_SECRET}, secretTraits) + + agentTraits, err := cfg.extractTraits("agent") + require.NoError(t, err) + require.Equal(t, []v2.ResourceType_Trait{v2.ResourceType_TRAIT_AGENT}, agentTraits) +} diff --git a/pkg/bsql/resource_types.go b/pkg/bsql/resource_types.go index 7a7f137a..adddd297 100644 --- a/pkg/bsql/resource_types.go +++ b/pkg/bsql/resource_types.go @@ -50,6 +50,14 @@ func (c Config) extractTraits(rtID string) ([]v2.ResourceType_Trait, error) { traits = append(traits, v2.ResourceType_TRAIT_APP) } + if rt.List.Map.Traits.Secret != nil { + traits = append(traits, v2.ResourceType_TRAIT_SECRET) + } + + if rt.List.Map.Traits.Agent != nil { + traits = append(traits, v2.ResourceType_TRAIT_AGENT) + } + return traits, nil } diff --git a/pkg/bsql/resources.go b/pkg/bsql/resources.go index 010e0307..c8df30e3 100644 --- a/pkg/bsql/resources.go +++ b/pkg/bsql/resources.go @@ -59,6 +59,12 @@ func (s *SQLSyncer) fetchTraits() map[string]bool { case mapTraits.App != nil: traits[appTraitType] = true + + case mapTraits.Secret != nil: + traits[secretTraitType] = true + + case mapTraits.Agent != nil: + traits[agentTraitType] = true } } @@ -372,6 +378,206 @@ func (s *SQLSyncer) mapRoleTrait(ctx context.Context, r *v2.Resource, rowMap map return nil } +func (s *SQLSyncer) mapSecretTrait(ctx context.Context, r *v2.Resource, rowMap map[string]any) error { + l := ctxzap.Extract(ctx) + + inputs := s.env.SyncInputs(rowMap) + mappings := s.config.List.Map.Traits.Secret + + var opts []sdkResource.SecretTraitOption + + if mappings.CredentialType != "" { + v, err := s.env.EvaluateString(ctx, mappings.CredentialType, inputs) + if err != nil { + return err + } + + var credentialType v2.SecretTrait_CredentialType + switch strings.ToLower(v) { + case "static_secret": + credentialType = v2.SecretTrait_CREDENTIAL_TYPE_STATIC_SECRET + case "asymmetric_key": + credentialType = v2.SecretTrait_CREDENTIAL_TYPE_ASYMMETRIC_KEY + case "certificate": + credentialType = v2.SecretTrait_CREDENTIAL_TYPE_CERTIFICATE + default: + l.Warn("unexpected credential type value in mapping", zap.String("credential_type", v)) + credentialType = v2.SecretTrait_CREDENTIAL_TYPE_UNSPECIFIED + } + opts = append(opts, sdkResource.WithSecretType(credentialType)) + } + + if mappings.CredentialDetail != "" { + v, err := s.env.EvaluateString(ctx, mappings.CredentialDetail, inputs) + if err != nil { + return err + } + if v != "" { + opts = append(opts, sdkResource.WithSecretDetail(v)) + } + } + + if mappings.ExpiresAt != "" { + v, err := s.env.EvaluateString(ctx, mappings.ExpiresAt, inputs) + if err != nil { + return err + } + if v != "" { + t, err := parseTimeWithEngine(v, s.dbEngine) + if err != nil { + l.Warn("failed to parse secret expires_at", zap.String("expires_at", v), zap.Error(err)) + } else { + opts = append(opts, sdkResource.WithSecretExpiresAt(*t)) + } + } + } + + if mappings.LastUsedAt != "" { + v, err := s.env.EvaluateString(ctx, mappings.LastUsedAt, inputs) + if err != nil { + return err + } + if v != "" { + t, err := parseTimeWithEngine(v, s.dbEngine) + if err != nil { + l.Warn("failed to parse secret last_used_at", zap.String("last_used_at", v), zap.Error(err)) + } else { + opts = append(opts, sdkResource.WithSecretLastUsedAt(*t)) + } + } + } + + t, err := sdkResource.NewSecretTrait(opts...) + if err != nil { + return err + } + + annos := annotations.Annotations(r.Annotations) + annos.Update(t) + r.Annotations = annos + + return nil +} + +func (s *SQLSyncer) mapAgentTrait(ctx context.Context, r *v2.Resource, rowMap map[string]any) error { + l := ctxzap.Extract(ctx) + + inputs := s.env.SyncInputs(rowMap) + mappings := s.config.List.Map.Traits.Agent + + var opts []sdkResource.AgentTraitOption + + if mappings.Status != "" { + v, err := s.env.EvaluateString(ctx, mappings.Status, inputs) + if err != nil { + return err + } + + var status v2.AgentTrait_AgentStatus + switch strings.ToLower(v) { + case "ready", "active", "enabled": + status = v2.AgentTrait_AGENT_STATUS_READY + case "disabled", "inactive": + status = v2.AgentTrait_AGENT_STATUS_DISABLED + case "deleted": + status = v2.AgentTrait_AGENT_STATUS_DELETED + default: + l.Warn("unexpected agent status value in mapping", zap.String("status", v)) + status = v2.AgentTrait_AGENT_STATUS_UNSPECIFIED + } + opts = append(opts, sdkResource.WithAgentStatus(status)) + } + + if mappings.IdentityResourceID != "" { + idValue, err := s.env.EvaluateString(ctx, mappings.IdentityResourceID, inputs) + if err != nil { + return err + } + + typeValue := "" + if mappings.IdentityResourceType != "" { + typeValue, err = s.env.EvaluateString(ctx, mappings.IdentityResourceType, inputs) + if err != nil { + return err + } + } + + if idValue != "" && typeValue != "" { + opts = append(opts, sdkResource.WithAgentIdentityResourceID(&v2.ResourceId{ + ResourceType: typeValue, + Resource: idValue, + })) + } else if idValue != "" { + l.Warn("agent identity_resource_id set without identity_resource_type; skipping identity reference") + } + } + + profile := make(map[string]interface{}) + for profileKey, profileValue := range mappings.Profile { + v, err := s.env.EvaluateString(ctx, profileValue, inputs) + if err != nil { + return err + } + profile[profileKey] = v + } + if len(profile) > 0 { + opts = append(opts, sdkResource.WithAgentProfile(profile)) + } + + t, err := sdkResource.NewAgentTrait(opts...) + if err != nil { + return err + } + + annos := annotations.Annotations(r.Annotations) + annos.Update(t) + r.Annotations = annos + + return nil +} + +// mapNonHumanIdentity attaches a kind-agnostic NonHumanIdentityTrait annotation +// (K3) to the resource. It is applied independently of the resource's primary +// trait, so a resource may be (for example) an app or role and also a non-human +// identity, or a non-human identity with no other trait. +func (s *SQLSyncer) mapNonHumanIdentity(ctx context.Context, r *v2.Resource, rowMap map[string]any) error { + l := ctxzap.Extract(ctx) + + mapping := s.config.List.Map.NonHumanIdentity + inputs := s.env.SyncInputs(rowMap) + + var nhiType v2.NonHumanIdentityTrait_NhiType + if mapping.NhiType != "" { + v, err := s.env.EvaluateString(ctx, mapping.NhiType, inputs) + if err != nil { + return err + } + + switch strings.ToLower(v) { + case "app_registration": + nhiType = v2.NonHumanIdentityTrait_NHI_TYPE_APP_REGISTRATION + case "assumable_role": + nhiType = v2.NonHumanIdentityTrait_NHI_TYPE_ASSUMABLE_ROLE + case "managed_identity": + nhiType = v2.NonHumanIdentityTrait_NHI_TYPE_MANAGED_IDENTITY + default: + l.Warn("unexpected nhi type value in mapping", zap.String("nhi_type", v)) + nhiType = v2.NonHumanIdentityTrait_NHI_TYPE_UNSPECIFIED + } + } + + detail := "" + if mapping.NhiDetail != "" { + v, err := s.env.EvaluateString(ctx, mapping.NhiDetail, inputs) + if err != nil { + return err + } + detail = v + } + + return sdkResource.WithNHIType(nhiType, detail)(r) +} + func (s *SQLSyncer) mapTraits(ctx context.Context, r *v2.Resource, rowMap map[string]any) error { l := ctxzap.Extract(ctx) @@ -397,6 +603,14 @@ func (s *SQLSyncer) mapTraits(ctx context.Context, r *v2.Resource, rowMap map[st if err := s.mapGroupTrait(ctx, r, rowMap); err != nil { return err } + case secretTraitType: + if err := s.mapSecretTrait(ctx, r, rowMap); err != nil { + return err + } + case agentTraitType: + if err := s.mapAgentTrait(ctx, r, rowMap); err != nil { + return err + } default: l.Warn("unexpected trait type in mapping", zap.String("trait", trait)) continue @@ -419,6 +633,14 @@ func (s *SQLSyncer) mapResource(ctx context.Context, rowMap map[string]any) (*v2 return nil, err } + // NHI is kind-agnostic and applied after the primary trait so it can + // co-exist with any (or no) trait. + if s.config.List.Map.NonHumanIdentity != nil { + if err := s.mapNonHumanIdentity(ctx, r, rowMap); err != nil { + return nil, err + } + } + return r, nil } diff --git a/pkg/bsql/sql_syncer.go b/pkg/bsql/sql_syncer.go index b1980dd9..06b14793 100644 --- a/pkg/bsql/sql_syncer.go +++ b/pkg/bsql/sql_syncer.go @@ -13,10 +13,12 @@ import ( ) const ( - userTraitType = "user" - appTraitType = "app" - groupTraitType = "group" - roleTraitType = "role" + userTraitType = "user" + appTraitType = "app" + groupTraitType = "group" + roleTraitType = "role" + secretTraitType = "secret" + agentTraitType = "agent" scopeCluster = "cluster" dbIterPageStateType = "db-iter" From 525d6091846716dd913e16336a192afc7ee7b3e1 Mon Sep 17 00:00:00 2001 From: Paul Querna Date: Fri, 5 Jun 2026 14:43:59 +0000 Subject: [PATCH 2/2] test: silence gosec G101 false positive in NHI trait tests CI's golangci-lint v2.11.4 flags the secret trait-config string literals as potential hardcoded credentials. Apply the same file-level //nolint:gosec convention used by user_syncer_test.go. Co-authored-by: c1-squire-dev[bot] --- pkg/bsql/nhi_test.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/bsql/nhi_test.go b/pkg/bsql/nhi_test.go index 648e7dc3..f5cc70a6 100644 --- a/pkg/bsql/nhi_test.go +++ b/pkg/bsql/nhi_test.go @@ -1,3 +1,4 @@ +//nolint:gosec // G101 false positives: string literals here are NHI trait config, not credentials. package bsql import (