fix(gcp): resolve binding edge cases and improve error handling#284
fix(gcp): resolve binding edge cases and improve error handling#284hughneale wants to merge 4 commits into
Conversation
- Reject organizations/ binding values in resolveCustomRoleTenant() since
organization-scope custom role creation via binding field is not yet implemented;
direct users to the provider-level organization_id config instead.
- Add explicit detection and warning for partial binding configuration: when some
statements have binding set but not all, the code now clearly warns that explicit
binding values will be ignored and inference will proceed instead (vs silently
falling through).
- Fix Statement.Binding code comment to clarify that organizations/{id} format is
NOT supported via the binding field (use provider config instead).
- Fix GCP documentation example that incorrectly stated IAM binding would be applied
at folder scope; clarified that when binding resolves to a project, the IAM
binding is applied at project scope.
- Update binding resolution order documentation to distinguish between missing
binding (all statements) vs partial binding (some statements), each with its own
warning behavior.
- Add test cases for organizations/ binding rejection and partial binding fallback
behavior (TestResolveCustomRoleTenant now covers 7 cases).
All tests pass. Build succeeds.
There was a problem hiding this comment.
Pull request overview
Updates the GCP provider’s custom-role binding logic to handle folder-tenant edge cases more predictably, with clearer warnings/errors and supporting documentation/tests.
Changes:
- Resolve custom-role creation/binding scope for folder tenants via explicit statement
binding(with legacy target-inference fallback). - Reject unsupported
bindingvalues (e.g.,organizations/...) and warn on partialbindingconfiguration. - Add/adjust unit tests and documentation to reflect the new binding behavior and resolution order.
Reviewed changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| internal/providers/gcp/rbac.go | Implements binding resolution/inference helpers and applies resolved scope to create/bind/unbind custom roles. |
| internal/providers/gcp/provider_test.go | Adds unit tests for binding resolution, inference, and rejection cases. |
| internal/models/role.go | Documents the new Statement.Binding field and provider-specific expectations. |
| docs/configuration/roles/migration.md | Adds migration guidance for adopting binding and explains warnings. |
| docs/configuration/roles/index.md | Documents binding, including formats, resolution order, and examples. |
Comments suppressed due to low confidence (1)
internal/providers/gcp/rbac.go:1
- The
tenantForRolederivation logic is duplicated in both the composite and non-composite branches. Consider extracting a small helper (e.g.,tenantForCustomRoleName(roleName, fallbackTenant)) to reduce repetition and keep future edge-case fixes (like org-scoped behavior) consistent across both paths.
package gcp
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Avoid duplicate fallback warnings in resolveCustomRoleTenant by only logging the generic missing-binding warning when no statements define binding. - Strengthen TestResolveCustomRoleTenant to assert warning output for both missing-binding and partial-binding fallback paths. - Align binding docs with runtime behavior: inference failure now documented as configuration error, and warning examples match actual log messages.
- Add Statement.ID field (snake_case, max 64 chars) for per-statement role naming
- Add snake_case custom validator
- Add statementRoleID() helper: single stmt = base name, multi-stmt = {base}_{id} or {base}_s{index}
- Add resolveStatementBindingTenant() for per-statement binding resolution
- Refactor AuthorizeRole (direct + Temporal) to create one custom role per allow statement
- Each statement independently resolves its binding tenant and gets its own role ID
- Update RevokeRole (direct + Temporal) to derive tenant from role resource path
- Add GCP targets warning (config-time + authorization-time)
- Add isGcpRole() and hasTargetsInStatements() config helpers
- Propagate Statement.ID across all literal constructions
- Add binding and ID visibility to Slack, email notifications and static HTML pages
- Update docs: statement structure table, YAML examples, migration guide sections
- Add tests for snake_case validator (11 cases) and statementRoleID (5 cases)
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 21 out of 21 changed files in this pull request and generated 5 comments.
Comments suppressed due to low confidence (3)
internal/models/provider_rbac.go:556
- The new Statement
bindingfield is dropped during permission validation: when rebuildingvalidatedStatements, the returned Statement copies ID/Operations/Targets/Conditions but notBinding. This will cause downstream providers (e.g. GCP resolveStatementBindingTenant) to never see the configured binding. PreserveBindingwhen constructing the validated Statement (and similarly anywhere else statements are rebuilt).
// Create validated statement with expanded operations
validatedStatements = append(validatedStatements, Statement{
ID: stmt.ID,
Operations: validatedOperations,
Targets: stmt.Targets,
Conditions: stmt.Conditions,
})
internal/models/provider_rbac.go:648
- Wildcard expansion currently rebuilds statements without copying the new
Bindingfield, so any configured binding will be lost after expansion. IncludeBinding: stmt.Bindingwhen appending the expanded Statement.
if len(ops) > 0 {
result = append(result, Statement{
ID: stmt.ID,
Operations: ops,
Targets: stmt.Targets,
Conditions: stmt.Conditions,
})
}
internal/config/roles.go:1333
- Provider filtering rebuilds statements without preserving the new
Bindingfield. If a role statement setsbinding, it will be silently lost after filtering, breaking binding-based scope resolution in providers. PreserveBinding(and consider whether it also needs provider-prefix filtering, analogous to operations/targets).
// Filter targets by provider
filteredTargets := c.filterByProvider(stmt.Targets, allowedProviders)
result = append(result, models.Statement{
ID: stmt.ID,
Operations: filteredOps,
Targets: filteredTargets,
Conditions: stmt.Conditions,
})
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| } | ||
|
|
||
| // Register custom validator for strict snake_case identifiers | ||
| // Used by Statement.Name field — must start with a lowercase letter, |
There was a problem hiding this comment.
Comment mismatch: this validator is registered for the new Statement ID field, but the comment says "Statement.Name". Update the comment to reference Statement.ID to avoid confusion (and keep the docs aligned with the struct tags).
| // Used by Statement.Name field — must start with a lowercase letter, | |
| // Used by Statement.ID field — must start with a lowercase letter, |
|
|
||
| - All statements within the same role that set `binding` must agree on the same value. Conflicting values produce a configuration error. | ||
| - `binding: "folders/..."` is rejected by the GCP provider — folders are not a valid scope for custom role creation. | ||
| - `binding` does not restrict which resources the operations act on; `targets` continues to control that. |
There was a problem hiding this comment.
This section states "targets continues to control" what resources operations act on, but later in the same document (and in code) GCP targets are explicitly metadata-only and not enforced. Reword this bullet to avoid implying GCP targets enforce scope (e.g. note that target enforcement is provider-specific, and GCP ignores them).
| - `binding` does not restrict which resources the operations act on; `targets` continues to control that. | |
| - `binding` does not itself restrict which resources operations apply to. Conceptually, `targets` define the resource scope, but enforcement is provider-specific (for example, the GCP provider treats `targets` as metadata only and does not enforce them). |
| - Must be **snake_case**: lowercase letters, digits, and underscores only (regex: `^[a-z][a-z0-9_]*$`). | ||
| - Maximum **64 characters**. | ||
| - Optional — roles with a single statement do not need an `id` (the base role name is used directly). | ||
| - The value is **not shown** in notifications or user-facing messages; it is an internal identifier only. |
There was a problem hiding this comment.
The doc says statement id is "not shown in notifications or user-facing messages", but the UI template internal/daemon/static/role.html now renders the ID column. Please clarify the doc (e.g. "not shown in Slack/email notifications"), or remove the claim.
| - The value is **not shown** in notifications or user-facing messages; it is an internal identifier only. | |
| - The value is primarily an internal identifier and is not included in Slack/email notifications, though it may appear in administrative UI tables. |
|
|
||
| // Warn if targets are present (GCP ignores targets in custom role definitions) | ||
| if len(stmt.Targets) > 0 { | ||
| logrus.Warnf("GCP custom roles do not enforce statement targets; targets are metadata only. Use binding field to control IAM assignment scope. Targets: %v", stmt.Targets) |
There was a problem hiding this comment.
permissionsToGcpPermissions logs a warning whenever a statement has targets. This function runs during custom role create/patch, which for composite roles can happen frequently; the warning is likely to be noisy (especially since config loading already warns). Consider lowering to Debug, rate-limiting, or emitting once per role/statement (or only when binding is missing and targets are being relied upon).
| logrus.Warnf("GCP custom roles do not enforce statement targets; targets are metadata only. Use binding field to control IAM assignment scope. Targets: %v", stmt.Targets) | |
| logrus.Debugf("GCP custom roles do not enforce statement targets; targets are metadata only. Use binding field to control IAM assignment scope. Targets: %v", stmt.Targets) |
| // resolveCustomRoleTenant determines the project tenant to use when creating and | ||
| // binding a custom GCP role for a set of permission statements. | ||
| // | ||
| // Resolution order: | ||
| // 1. If every statement has an explicit Binding set, and all bindings resolve to | ||
| // the same project, use that project. A folders/ binding is rejected because | ||
| // GCP does not support custom roles at folder scope. | ||
| // 2. Otherwise fall back to inferring the project from Targets (legacy behaviour, | ||
| // emits a deprecation warning so operators can migrate to explicit Binding). | ||
| // 3. If neither succeeds the error from inference is returned. | ||
| func resolveCustomRoleTenant(statements models.RoleStatements, fallbackTenantID string) (*models.ProviderTenant, error) { |
There was a problem hiding this comment.
resolveCustomRoleTenant is added and has dedicated tests, but it doesn't appear to be used anywhere in the provider code path (per-statement resolution uses resolveStatementBindingTenant instead). If it's intentionally unused, consider removing it (and its tests) to avoid dead-code maintenance; otherwise wire it into the authorization/revocation flow where multi-statement roles need a single resolved tenant.
Reject organizations/ binding values in resolveCustomRoleTenant() since organization-scope custom role creation via binding field is not yet implemented; direct users to the provider-level organization_id config instead.
Add explicit detection and warning for partial binding configuration: when some statements have binding set but not all, the code now clearly warns that explicit binding values will be ignored and inference will proceed instead (vs silently falling through).
Fix Statement.Binding code comment to clarify that organizations/{id} format is NOT supported via the binding field (use provider config instead).
Fix GCP documentation example that incorrectly stated IAM binding would be applied at folder scope; clarified that when binding resolves to a project, the IAM binding is applied at project scope.
Update binding resolution order documentation to distinguish between missing binding (all statements) vs partial binding (some statements), each with its own warning behavior.
Add test cases for organizations/ binding rejection and partial binding fallback behavior (TestResolveCustomRoleTenant now covers 7 cases).
All tests pass. Build succeeds.
Summary
Closes #
Type of Change
feat– New feature (minor version bump)fix– Bug fix (patch version bump)refactor– Code refactoring, no functional changedocs– Documentation onlytest– Adding or updating testschore– Build, CI, dependency updatesmajor/BREAKING CHANGE– Breaking change (major version bump)What Changed
Provider / Workflow / Role Changes
config/providers/)config/roles/)config/workflows/)examples/)Security Considerations
Security notes (if applicable):
Testing
go test ./...)Manual test scenario (if applicable):
Breaking Changes
Migration steps (if applicable):
Documentation
docs/updatedREADME.mdupdatedChecklist
mainfeat:,fix:,major:, etc.)