diff --git a/charts/operator/templates/deployment.yaml b/charts/operator/templates/deployment.yaml index 5cb261c..74dc07d 100644 --- a/charts/operator/templates/deployment.yaml +++ b/charts/operator/templates/deployment.yaml @@ -64,6 +64,22 @@ spec: value: {{ .Values.config.region | quote }} - name: AGENTS_SSM_PATH_PREFIX value: {{ .Values.config.ssmPathPrefix | quote }} + {{- with .Values.config.tags.costCenter }} + - name: AGENTS_COST_CENTER + value: {{ . | quote }} + {{- end }} + {{- with .Values.config.tags.businessUnit }} + - name: AGENTS_BUSINESS_UNIT + value: {{ . | quote }} + {{- end }} + {{- with .Values.config.tags.dataClassification }} + - name: AGENTS_DATA_CLASSIFICATION + value: {{ . | quote }} + {{- end }} + {{- with .Values.config.tags.compliance }} + - name: AGENTS_COMPLIANCE + value: {{ . | quote }} + {{- end }} {{- with .Values.config.oidc.providerArn }} - name: AGENTS_OIDC_PROVIDER_ARN value: {{ . | quote }} diff --git a/charts/operator/values.yaml b/charts/operator/values.yaml index 662c864..48403ca 100644 --- a/charts/operator/values.yaml +++ b/charts/operator/values.yaml @@ -53,6 +53,16 @@ config: ssmPathPrefix: /eks-agent-platform environment: "" region: us-west-2 + # Org-dimension tag values stamped on the tenant IRSA roles the operator + # creates (resource-tagging standard, required tier). Empty falls back to the + # operator's defaults (platform-engineering / engineering / internal / soc2); + # override per-env via values-.yaml (e.g. dataClassification: confidential + # in production). + tags: + costCenter: "" + businessUnit: "" + dataClassification: "" + compliance: "" # EKS OIDC wiring — required by PlatformReconciler.ensureIamRole to mint # tenant IRSA trust policies. Set per-env values; the ApplicationSet # passes them in via the values-.yaml file resolved from the diff --git a/operators/cmd/main.go b/operators/cmd/main.go index 54e8498..26d3d23 100644 --- a/operators/cmd/main.go +++ b/operators/cmd/main.go @@ -78,6 +78,14 @@ func main() { var oidcIssuerHost string var disableAWS bool + // Org-dimension tag values stamped on tenant IRSA roles (resource-tagging + // standard). Env-level constants for the cluster the operator serves; + // tenantRoleTags falls back to landing-zone env.hcl defaults when unset. + var costCenter string + var businessUnit string + var dataClassification string + var compliance string + flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "Address the metric endpoint binds to.") flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "Address the health probe binds to.") flag.BoolVar(&enableLeaderElection, "leader-elect", true, "Enable leader election.") @@ -99,6 +107,10 @@ func main() { flag.StringVar(®ion, "region", os.Getenv("AGENTS_REGION"), "AWS region. Defaults to credential-chain region if empty.") flag.StringVar(&oidcProviderARN, "oidc-provider-arn", os.Getenv("AGENTS_OIDC_PROVIDER_ARN"), "EKS cluster OIDC provider ARN; used in tenant IRSA trust policies.") flag.StringVar(&oidcIssuerHost, "oidc-issuer-host", os.Getenv("AGENTS_OIDC_ISSUER_HOST"), "EKS OIDC issuer host (oidc.eks..amazonaws.com/id/).") + flag.StringVar(&costCenter, "cost-center", os.Getenv("AGENTS_COST_CENTER"), "Org cost-center tag stamped on tenant IRSA roles (resource-tagging standard).") + flag.StringVar(&businessUnit, "business-unit", os.Getenv("AGENTS_BUSINESS_UNIT"), "Org business-unit tag stamped on tenant IRSA roles.") + flag.StringVar(&dataClassification, "data-classification", os.Getenv("AGENTS_DATA_CLASSIFICATION"), "Org data-classification tag stamped on tenant IRSA roles.") + flag.StringVar(&compliance, "compliance", os.Getenv("AGENTS_COMPLIANCE"), "Org compliance tag stamped on tenant IRSA roles.") flag.BoolVar(&disableAWS, "disable-aws", false, "Skip AWS client init + SSM config load (k8s-side reconciliation only).") opts := zap.Options{Development: false} opts.BindFlags(flag.CommandLine) @@ -161,6 +173,10 @@ func main() { OIDCProviderARN: oidcProviderARN, OIDCIssuerHost: oidcIssuerHost, Environment: environment, + CostCenter: costCenter, + BusinessUnit: businessUnit, + DataClassification: dataClassification, + Compliance: compliance, } platformReconciler.AWSCfg = controller.PlatformAWSConfig{ // cmk-data ARN isn't in operatorconfig today; the operator reads diff --git a/operators/internal/controller/platform_iam.go b/operators/internal/controller/platform_iam.go index 4174022..af48cdf 100644 --- a/operators/internal/controller/platform_iam.go +++ b/operators/internal/controller/platform_iam.go @@ -124,6 +124,56 @@ type IAMConfig struct { OIDCProviderARN string OIDCIssuerHost string // e.g. oidc.eks.us-west-2.amazonaws.com/id/EXAMPLE Environment string + + // Org-dimension tag values for tenant IRSA roles (resource-tagging + // standard, required tier). Sourced from the operator's deploy config + // (AGENTS_COST_CENTER / _BUSINESS_UNIT / _DATA_CLASSIFICATION / _COMPLIANCE). + // tenantRoleTags falls back to the landing-zone env.hcl defaults when these + // are unset, so a tenant role always carries the keys cloudgov gates on. + CostCenter string + BusinessUnit string + DataClassification string + Compliance string +} + +// orDefault returns def when v is empty. +func orDefault(v, def string) string { + if v == "" { + return def + } + return v +} + +// tenantRoleTags builds the IAM tag set for a tenant IRSA role. +// +// It preserves the keys the rest of the system depends on and must not rename: +// PlatformId (the BudgetPolicy reconciler groups Cost Explorer by it), Tenant, +// and Persona. On top of those it carries the required-tier resource-tagging +// keys cloudgov gates on — Project, Repository, Component, Team, CostCenter, +// BusinessUnit, DataClassification, Compliance — plus Environment and ManagedBy. +// ManagedBy is "eks-agent-platform" (the operator owns these roles' lifecycle, +// unlike the opentofu-managed roles in landing-zone). +func tenantRoleTags(p *platformv1alpha1.Platform, cfg IAMConfig) []iamtypes.Tag { + tag := func(k, v string) iamtypes.Tag { + return iamtypes.Tag{Key: aws.String(k), Value: aws.String(v)} + } + return []iamtypes.Tag{ + // Load-bearing keys — PlatformId drives BudgetPolicy cost attribution. + tag("PlatformId", p.Name), + tag("Tenant", p.Spec.Tenant), + tag("Persona", p.Spec.Persona), + // Required-tier resource-tagging keys. + tag("Environment", cfg.Environment), + tag("ManagedBy", "eks-agent-platform"), + tag("Project", "eks-agent-platform"), + tag("Repository", "nanohype/eks-agent-platform"), + tag("Component", "tenant-iam"), + tag("Team", p.Spec.Tenant), + tag("CostCenter", orDefault(cfg.CostCenter, "platform-engineering")), + tag("BusinessUnit", orDefault(cfg.BusinessUnit, "engineering")), + tag("DataClassification", orDefault(cfg.DataClassification, "internal")), + tag("Compliance", orDefault(cfg.Compliance, "soc2")), + } } // ensureIamRole creates (or no-ops if already present) the tenant IRSA @@ -191,13 +241,7 @@ func (r *PlatformReconciler) ensureIamRole(ctx context.Context, p *platformv1alp Path: aws.String(path), AssumeRolePolicyDocument: aws.String(trust), Description: aws.String(fmt.Sprintf("Tenant IRSA role for Platform %s (tenant %s)", p.Name, p.Spec.Tenant)), - Tags: []iamtypes.Tag{ - {Key: aws.String("PlatformId"), Value: aws.String(p.Name)}, - {Key: aws.String("Tenant"), Value: aws.String(p.Spec.Tenant)}, - {Key: aws.String("Persona"), Value: aws.String(p.Spec.Persona)}, - {Key: aws.String("Environment"), Value: aws.String(cfg.Environment)}, - {Key: aws.String("ManagedBy"), Value: aws.String("eks-agent-platform")}, - }, + Tags: tenantRoleTags(p, cfg), } if cfg.TenantPermissionsBoundaryARN != "" { createInput.PermissionsBoundary = aws.String(cfg.TenantPermissionsBoundaryARN) diff --git a/operators/internal/controller/platform_iam_test.go b/operators/internal/controller/platform_iam_test.go index 7d01b10..f53a50a 100644 --- a/operators/internal/controller/platform_iam_test.go +++ b/operators/internal/controller/platform_iam_test.go @@ -11,6 +11,9 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" iamtypes "github.com/aws/aws-sdk-go-v2/service/iam/types" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + platformv1alpha1 "github.com/nanohype/eks-agent-platform/operators/api/platform/v1alpha1" ) func TestSuspensionFromTags(t *testing.T) { @@ -48,3 +51,51 @@ func TestSuspensionFromTags(t *testing.T) { }) } } + +func tagMap(tags []iamtypes.Tag) map[string]string { + m := make(map[string]string, len(tags)) + for _, t := range tags { + m[aws.ToString(t.Key)] = aws.ToString(t.Value) + } + return m +} + +func TestTenantRoleTags(t *testing.T) { + p := &platformv1alpha1.Platform{ + ObjectMeta: metav1.ObjectMeta{Name: "acme"}, + Spec: platformv1alpha1.PlatformSpec{Tenant: "acme-team", Persona: "founder"}, + } + + // Empty org-dim config: the required keys must still be present (defaults). + got := tagMap(tenantRoleTags(p, IAMConfig{Environment: "production"})) + + // The required-tier resource-tagging keys cloudgov gates on, plus the + // load-bearing PlatformId / Tenant / Persona the rest of the system reads. + for _, k := range []string{ + "Environment", "ManagedBy", "Project", "Repository", "Component", "Team", + "CostCenter", "BusinessUnit", "DataClassification", "Compliance", + "PlatformId", "Tenant", "Persona", + } { + if got[k] == "" { + t.Errorf("tenantRoleTags missing/empty key %q (have %v)", k, got) + } + } + if got["PlatformId"] != "acme" { + t.Errorf("PlatformId: got %q want acme", got["PlatformId"]) + } + if got["ManagedBy"] != "eks-agent-platform" { + t.Errorf("ManagedBy: got %q want eks-agent-platform", got["ManagedBy"]) + } + if got["CostCenter"] != "platform-engineering" { + t.Errorf("CostCenter default: got %q want platform-engineering", got["CostCenter"]) + } + + // Explicit config wins over the defaults. + got = tagMap(tenantRoleTags(p, IAMConfig{ + Environment: "dev", CostCenter: "research", BusinessUnit: "labs", + DataClassification: "confidential", Compliance: "hipaa", + })) + if got["CostCenter"] != "research" || got["Compliance"] != "hipaa" { + t.Errorf("config override not applied: %v", got) + } +}