ACL Commands
Fetch, validate, update, and simulate ACL policies using the CloudSignal CLI.
All ACL commands are under cloudsignal acl. They manage the same v2 ACL policy you see in the dashboard's Access Control page.
Get Policy
Fetch the current ACL policy for your organization:
cloudsignal acl getOutput is JSON, formatted when run interactively:
{
"version": "2",
"default": "deny",
"global": [
{ "topic": "broadcast/#", "action": "sub" }
],
"rules": [
{ "topic": "/{email}/inbox", "action": "sub", "binding": "email" }
],
"publishers": []
}Save to file
cloudsignal acl get > policy.jsonWhen piped, output is compact JSON (no formatting). Force a format with:
cloudsignal acl get --pretty # Always formatted
cloudsignal acl get --compact # Always minifiedValidate Policy
Check a policy file for errors before deploying:
cloudsignal acl validate policy.jsonValidation runs in two stages:
- Local check - JSON schema validation (fast, no network)
- Server check - semantic validation against your organization's state
$ cloudsignal acl validate policy.json
✓ Policy is valid (3 rules, 1 global rule, 0 publishers)If there are errors:
$ cloudsignal acl validate bad-policy.json
✗ Policy has errors:
error: global[0] - invalid action "read" (must be sub, pub, or pub+sub)
warning: rules[1] - channel binding without require_grantExit codes
| Code | Meaning |
|---|---|
0 | Valid (warnings are OK) |
1 | Errors found |
Flags
| Flag | Description |
|---|---|
--local-only | Skip server validation, only run local schema check |
Warnings don't cause a non-zero exit code. Only errors do.
ACL v2.1 - {$self} Identity Binding
Version "2.1" adds identity-bound topic rules. A single rule covers every authenticated user without per-user entries.
The {$self} placeholder
{$self} resolves to claims[rule.binding] at evaluation time. The broker substitutes the connecting user's own claim value before matching the topic.
v2 (literal form):
{ "topic": "/{email}/inbox", "action": "sub", "binding": "email" }The variable {email} is a literal pattern matched against a claim table entry. One rule, one binding.
v2.1 ({$self} form):
{ "topic": "/{$self}/inbox", "action": "sub", "binding": "email" }{$self} means "substitute my own email claim here." A user with email = alice@example.com can only access /alice@example.com/inbox - never another user's inbox, even though they share the same rule shape.
{$self} must occupy a complete topic segment. prefix-{$self}-suffix is a schema error.
The binding field in v2.1
binding value | Meaning | {$self} in topic |
|---|---|---|
Claim key (agent_id, user_id, email, etc.) | {$self} resolves to claims[<key>] | Required |
"authenticated" | Rule applies to any authenticated user; no substitution | Not allowed (schema error) |
| Absent | Allowed only in global array | Not allowed |
"authenticated" is useful for read-only wildcard rules - e.g. letting any agent subscribe to gtm/agents/+/card without identity substitution.
Reserved {$...} namespace
Only {$self} is implemented. Any other {$<name>} (like {$org}, {$tenant}) is a schema error today. The namespace is reserved for future built-ins.
Schema validation errors
The following are blocked at deploy time:
| Condition | Error message |
|---|---|
{$self} with no binding | rules[N]: {$self} in topic requires a "binding" field naming the claim to resolve |
{$self} with binding: "authenticated" | rules[N]: {$self} cannot be used with binding: "authenticated" (no claim to resolve) |
Unknown {$<name>} placeholder | rules[N]: unknown placeholder "{$name}" - only {$self} is implemented; the {$...} namespace is reserved |
Literal {var} form in v2.1 policy | rules[N]: literal placeholder "{var}" is not allowed in v2.1 - use {$self} with binding: "var" instead |
{$self} embedded mid-segment | rules[N]: {$self} must be a complete topic segment (got "prefix-{$self}-suffix"); cannot be embedded mid-segment |
Claim-value safety
When {$self} resolves, the substituted value is checked for unsafe characters. If the claim value contains /, +, #, or a null byte, the rule is treated as no-match and a warning is logged. This prevents a compromised claim from injecting extra topic segments or wildcards.
Example - GTM agent policy
{
"version": "2.1",
"default": "deny",
"rules": [
{ "topic": "gtm/agents/{$self}/card", "action": "pub+sub", "binding": "agent_id" },
{ "topic": "gtm/agents/+/card", "action": "sub", "binding": "authenticated" },
{ "topic": "gtm/tasks/{$self}/inbox", "action": "sub", "binding": "agent_id" },
{ "topic": "gtm/tasks/{$self}/results", "action": "sub", "binding": "agent_id" },
{ "topic": "gtm/tasks/+/inbox", "action": "pub", "binding": "authenticated" },
{ "topic": "gtm/users/{$self}/messages", "action": "pub+sub", "binding": "user_id" },
{ "topic": "gtm/users/{$self}/notifications", "action": "pub+sub", "binding": "user_id" }
]
}An agent with claims.agent_id = "scout" gets pub+sub on gtm/agents/scout/card only - never gtm/agents/analyst/card. Any authenticated agent can subscribe gtm/agents/+/card (the "authenticated" rule) but cannot publish to it. Identity isolation and read-access wildcard coexist in the same policy.
Validating a v2.1 policy
cloudsignal acl validate policy-v21.json✓ Policy is valid (8 rules, 0 global rules, 0 publishers)With a broken rule - {$self} missing its binding:
cloudsignal acl validate broken.json✗ Local schema validation failed:
error: rules[0]: {$self} in topic requires a "binding" field naming the claim to resolveMigrating from v2 to v2.1
The migration script rewrites all {<binding>} literals to {$self} and bumps "version" to "2.1" in place. It's idempotent - safe to re-run.
# Preview changes without writing
DATABASE_URL=postgresql://... python scripts/migrate_acl_v2_to_v2_1.py --dry-run
# Apply
DATABASE_URL=postgresql://... python scripts/migrate_acl_v2_to_v2_1.pyThe script logs before/after rule counts for each organization. Rules where the literal variable name doesn't match the rule's binding field are flagged for manual review - the script won't guess intent.
Existing v2 policies continue to work. The server only enforces v2.1 for newly saved policies. Use the migration script before your next policy deploy.
Update Policy
Deploy a policy from a JSON file:
cloudsignal acl update policy.jsonThis:
- Reads the file
- Strips the
$schemafield (if present) - Validates locally (version and default checks)
- Sends to the server via
PUT /api/v2/acl-policy - The server validates and saves
$ cloudsignal acl update policy.json
Validating policy...
✓ Policy is valid
Deploying policy...
✓ Policy deployed successfullyFlags
| Flag | Description |
|---|---|
--dry-run | Validate only, don't deploy. Calls the validate endpoint instead of deploying. |
--skip-local-validation | Skip local checks, let the server validate |
Dry run example
cloudsignal acl update policy.json --dry-runValidating policy (dry run)...
✓ Policy is valid (dry run - not deployed)--dry-run validates policy content only. It does not check authorization or organization state.
Simulate Access
Test whether a specific MQTT user would be allowed to publish or subscribe to a topic:
cloudsignal acl simulate \
--user 550e8400-e29b-41d4-a716-446655440000 \
--topic "org/devices/sensor-1/telemetry" \
--action publish✓ ALLOWED
Matched rule: org/devices/+/telemetry (pub+sub)
Reason: Matched global rulecloudsignal acl simulate \
--user 550e8400-e29b-41d4-a716-446655440000 \
--topic "org/admin/config" \
--action subscribe✗ DENIED
Reason: No matching rule found, default policy is denyRequired flags
| Flag | Description |
|---|---|
--user <uuid> | MQTT user UUID |
--topic <topic> | MQTT topic to test |
--action <publish|subscribe> | Action to simulate |
Optional flags
| Flag | Description | Default |
|---|---|---|
--qos <0|1|2> | QoS level | 0 |
The --user flag requires a UUID. You can find user UUIDs in the dashboard under Clients. A --username flag for convenience lookups is planned for a future release.
Typical Workflow
A common workflow for managing ACL policies with the CLI:
# 1. Fetch current policy
cloudsignal acl get > policy.json
# 2. Edit in your preferred editor
code policy.json
# 3. Validate changes
cloudsignal acl validate policy.json
# 4. Test with simulation
cloudsignal acl simulate \
--user <user-uuid> \
--topic "my/test/topic" \
--action publish
# 5. Deploy
cloudsignal acl update policy.jsonThis gives you version control over your ACL policies - commit policy.json to git and track changes over time.