Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 94 additions & 1 deletion docs/configuration/roles/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,14 +216,17 @@ Each permission statement is an object with the following fields:

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `id` | string | No | Identifier for this statement, used to derive per-statement custom role names (snake_case, max 64 chars) |
| `operations` | array | Yes | Actions that can be performed (provider-specific) |
| `targets` | array | No | Resources the operations apply to (provider-specific) |
| `conditions` | object | No | Provider-specific conditions that must be met |
| `binding` | string | No | Explicit CSP resource where the role is created and bound (see [Binding](#binding)) |

```yaml
permissions:
allow:
- operations: # What actions to allow
- id: secret_read # Optional: identifier for per-statement role naming
operations: # What actions to allow
- action1
- action2
targets: # Which resources these actions apply to
Expand All @@ -232,6 +235,7 @@ permissions:
conditions: # Optional provider-specific conditions
ConditionOperator:
ConditionKey: ConditionValue
binding: "" # Optional: explicit scope for role creation / IAM binding
deny:
- operations:
- action3
Expand Down Expand Up @@ -492,6 +496,95 @@ permissions:
{: .note}
Conditions follow the same structure as AWS IAM policy conditions. The outer key is the condition operator (e.g., `StringEquals`, `IpAddress`, `Bool`), and the inner key-value pair is the condition key and expected value. Refer to the [AWS IAM Condition documentation](https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements_condition.html) for the full list of supported operators and keys.

### Binding

The `binding` field on a statement explicitly declares **where** a provider-managed custom role is created and where the resulting IAM binding is applied. This is separate from `targets`, which declares *what resources* the operations act on.

This field is most important when the **request tenant** (the GCP folder, Azure subscription, or AWS account making the access request) differs from the **scope** where the custom role must be created. For example:

- A request tenant is a **GCP folder** — but custom GCP roles can only be created at the `project` or `organization` level.
- A statement's `targets` reference resources inside a specific project — so the custom role should be created in that project.

Without an explicit `binding`, the GCP provider attempts to infer a project from `targets` (legacy behaviour, emits a deprecation warning). Setting `binding` explicitly eliminates the ambiguity and the warning.

#### Format per provider

| Provider | Format | Example |
|----------|--------|---------|
| GCP | `projects/{id}` | `projects/my-project` |
| Azure | `/subscriptions/{id}` or `/subscriptions/{id}/resourceGroups/{rg}` | `/subscriptions/00000000-0000-0000-0000-000000000000` |
| AWS | `arn:aws:iam::{account-id}:root` | `arn:aws:iam::123456789012:root` |

{: .note}
**GCP organization scope**: creating custom roles at organization scope via the `binding` field is not currently supported. To use organization-scoped custom roles, set `organization_id` in the provider configuration instead. Specifying `binding: "organizations/..."` will produce a configuration error.

#### Resolution order

1. If `binding` is set on **all** statements in the role, that value is used.
2. If `binding` is absent from one or more statements, the provider falls back to inferring a scope from `targets` (legacy, deprecated — a warning is logged). If **some** statements have `binding` set but not all, the explicit binding values are ignored and a targeted warning is emitted.
3. If inference also fails, a configuration error is returned and the request is rejected.

All statements in a role that set `binding` must agree on the same value. Conflicting `binding` values across statements produce a validation error.

#### GCP example — folder tenant with project-scoped custom role

```yaml
roles:
secrets-reader:
name: Secrets Reader
description: Read-only access to Secret Manager secrets in the secrets project
enabled: true

permissions:
allow:
# Create the custom role in 'thand-secrets', not in the folder tenant
- binding: "projects/thand-secrets"
operations:
- "gcp-prod:secretmanager.secrets.get"
- "gcp-prod:secretmanager.secrets.list"
- "gcp-prod:secretmanager.versions.access"
targets:
- "projects/thand-secrets/*"

# This role can now be requested via a folder-level tenant (e.g. folders/205090528354).
# The custom role is created in 'thand-secrets' and the IAM binding is applied at
# the project level (projects/thand-secrets), not at the folder level.
```

#### Azure example

```yaml
roles:
storage-reader:
name: Storage Reader
permissions:
allow:
- binding: "/subscriptions/00000000-0000-0000-0000-000000000000"
operations:
- "Microsoft.Storage/storageAccounts/read"
targets:
- "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/data/*"
```

#### AWS example

```yaml
roles:
s3-reader:
name: S3 Reader
permissions:
allow:
- binding: "arn:aws:iam::123456789012:root"
operations:
- "s3:GetObject"
- "s3:ListBucket"
targets:
- "arn:aws:s3:::my-bucket/*"
```

{: .important}
`binding` is about **where** a custom role is created / where the IAM binding is applied. It is not an additional resource restriction — `targets` still controls which resources the operations act on.

### Allow/Deny Conflict Resolution

When the same action appears in both `allow` and `deny` lists, the system resolves conflicts using clear precedence rules.
Expand Down
147 changes: 147 additions & 0 deletions docs/configuration/roles/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,151 @@ See the [Conditions documentation](./index#conditions) for full details and AWS

---

## 6. New: Binding Field on Statements

The `binding` field is an optional property on permission statements that declares the explicit CSP resource where a custom role should be created and where the resulting IAM binding should be applied.

### Why it exists

Some providers create custom roles at a different scope than the request tenant. The most common case is **GCP**: custom roles can only be created at the `projects/{id}` or `organizations/{id}` level, but a request tenant may be a folder (`folders/{id}`). Without `binding`, the GCP provider would fail when asked to create a custom role for a folder tenant.

The field exists to resolve this explicitly and cleanly, regardless of provider. See [Binding](./index#binding) in the reference documentation for the full format per provider.

### No migration required — but a deprecation warning may appear

If your existing roles have `permissions.allow` statements that include `targets` pointing at a specific project (e.g. `projects/my-project/...`), the GCP provider previously attempted to infer the binding project from those targets whenever the request tenant was a folder. This inference still works, but it now emits a **deprecation warning** in the agent logs:
Comment thread
hughneale marked this conversation as resolved.

```
level=warning msg="permissions.allow statements are missing 'binding'; inferring project from targets. Set an explicit 'binding' on each statement to remove this warning."
```

If only some statements in the role set `binding`, a different warning is emitted:

```
level=warning msg="some permissions.allow statements have 'binding' set but not all; the explicit binding values will be ignored and the project will be inferred from targets instead. Set 'binding' on every statement or remove it from all statements."
```

To silence the warning and make the intent explicit, add `binding` to the relevant statements:

```yaml
# Before (still works, but logs a deprecation warning)
permissions:
allow:
- operations:
- "gcp-prod:secretmanager.secrets.get"
targets:
- "projects/my-project/secrets/*"

# After (explicit, no warning)
permissions:
allow:
- binding: "projects/my-project"
operations:
- "gcp-prod:secretmanager.secrets.get"
targets:
- "projects/my-project/secrets/*"
```

### Validation

- 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.

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
- `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).

Copilot uses AI. Check for mistakes.

---

## 7. New: Statement ID Field

The `id` field is an optional property on permission statements that provides a stable identifier for per-statement custom role naming. It is most useful when a role contains **multiple statements**, each requiring its own custom role in the provider.

### Why it exists

When a role has multiple `allow` statements, the provider must create a separate custom role for each one. Without `id`, the generated role names use a positional index suffix (e.g., `thand_my_role_s0`, `thand_my_role_s1`). This means reordering or inserting statements changes the generated names, which can cause unnecessary role deletions and recreations.

Setting `id` on each statement produces **stable, human-readable** names:

```yaml
# Without id — positional names (fragile)
permissions:
allow:
- operations: # → thand_my_role_s0
- secretmanager.secrets.get
- operations: # → thand_my_role_s1
- storage.buckets.get

# With id — stable names
permissions:
allow:
- id: secrets_read # → thand_my_role_secrets_read
operations:
- secretmanager.secrets.get
- id: storage_read # → thand_my_role_storage_read
operations:
- storage.buckets.get
```

### Validation

- 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.

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
- 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.

Copilot uses AI. Check for mistakes.

---

## 8. Provider-Specific Behavior: GCP Targets

### GCP-Specific Note: Targets Are Metadata-Only

For GCP roles, the `targets` field within permission statements is **preserved in the role definition but not enforced**. Only the `operations` field is used when building custom IAM roles.

This means:
- **`targets` are metadata**: You can include targets for documentation or reference, but GCP's role creation system (IAM API) ignores them entirely.
- **`operations` are enforced**: Only the operations you list in `operations` are included in the generated custom role.
- **Use `binding` to scope IAM bindings**: To explicitly control which project or resource a custom role is assigned to a user, use the `binding` field. `binding` determines the IAM assignment scope, not `targets`.

### Example: GCP Role with Targets (Targets Ignored)

```yaml
permissions:
allow:
- binding: "projects/my-project"
operations:
- secretmanager.secrets.get
- secretmanager.secrets.list
targets:
- "projects/my-project/secrets/*" # This line is metadata only; GCP ignores it
```

The custom role created in GCP will only include `secretmanager.secrets.get` and `secretmanager.secrets.list`. The `targets` line provides documentation to users or tools about which resources these operations apply to, but it does not limit the role itself.

### Avoid Relying on Targets for GCP Resource Scoping

**Incorrect approach** (targets won't enforce scope):
```yaml
# DON'T do this—targets alone won't restrict scope
permissions:
allow:
- operations:
- storage.buckets.get
targets:
- "projects/my-project/buckets/my-bucket" # This is ignored!
```

**Correct approach** (use binding for scope control):
```yaml
# DO this—binding controls both role creation scope and IAM binding scope
permissions:
allow:
- binding: "projects/my-project" # Role created here; binding applied here
operations:
- storage.buckets.get
targets:
- "projects/my-project/buckets/my-bucket" # Optional: documentation
```

---

## Summary

| Feature | Manual Action Required | Notes |
Expand All @@ -248,6 +393,8 @@ See the [Conditions documentation](./index#conditions) for full details and AWS
| Deny scopes | Optional | New feature, add when ready |
| Conditions | Optional | New feature, AWS-only currently |
| Composite field | No | System-managed, do not set |
| Binding field | Recommended | Silences deprecation warning when tenant ≠ role creation scope |
| Statement ID | Optional | Stable per-statement custom role names for multi-statement roles |

{: .note}
While auto-migration ensures your existing configurations continue to work, we recommend updating your YAML files to the new format when convenient. The new format is more expressive, supports conditions and domain scopes, and makes the relationship between operations and their target resources explicit.
32 changes: 32 additions & 0 deletions internal/common/validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,38 @@ func GetValidator() *validator.Validate {
// Log error but don't panic
}

// Register custom validator for strict snake_case identifiers
// Used by Statement.Name field — must start with a lowercase letter,

Copilot AI Mar 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
// Used by Statement.Name field — must start with a lowercase letter,
// Used by Statement.ID field — must start with a lowercase letter,

Copilot uses AI. Check for mistakes.
// followed by lowercase alphanumeric characters and underscores only.
if err := validatorInstance.RegisterValidation("snake_case", func(fl validator.FieldLevel) bool {
value := fl.Field().String()
matched, _ := regexp.MatchString(`^[a-z][a-z0-9_]*$`, value)
return matched
}); err != nil {
// Log error but don't panic
}

// Register custom validator for CSP binding resource identifiers.
// Ensures the value starts with a known cloud provider resource prefix
// so typos are caught at config-load time rather than at authorization time.
if err := validatorInstance.RegisterValidation("csp_binding", func(fl validator.FieldLevel) bool {
value := fl.Field().String()
for _, prefix := range []string{
"projects/", // GCP
"organizations/", // GCP
"folders/", // GCP
"/subscriptions/", // Azure
"arn:aws:", // AWS
} {
if strings.HasPrefix(value, prefix) {
return true
}
}
return false
}); err != nil {
// Log error but don't panic
}

// Call all registered custom validator functions
validatorRegistrationsMu.Lock()
registrations := validatorRegistrations
Expand Down
Loading
Loading