CloudSignal Docs
CLI

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 get

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

When piped, output is compact JSON (no formatting). Force a format with:

cloudsignal acl get --pretty    # Always formatted
cloudsignal acl get --compact   # Always minified

Validate Policy

Check a policy file for errors before deploying:

cloudsignal acl validate policy.json

Validation runs in two stages:

  1. Local check - JSON schema validation (fast, no network)
  2. 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_grant

Exit codes

CodeMeaning
0Valid (warnings are OK)
1Errors found

Flags

FlagDescription
--local-onlySkip 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 valueMeaning{$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 substitutionNot allowed (schema error)
AbsentAllowed only in global arrayNot 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:

ConditionError message
{$self} with no bindingrules[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>} placeholderrules[N]: unknown placeholder "{$name}" - only {$self} is implemented; the {$...} namespace is reserved
Literal {var} form in v2.1 policyrules[N]: literal placeholder "{var}" is not allowed in v2.1 - use {$self} with binding: "var" instead
{$self} embedded mid-segmentrules[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 resolve

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

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

This:

  1. Reads the file
  2. Strips the $schema field (if present)
  3. Validates locally (version and default checks)
  4. Sends to the server via PUT /api/v2/acl-policy
  5. The server validates and saves
$ cloudsignal acl update policy.json
Validating policy...
✓ Policy is valid
Deploying policy...
✓ Policy deployed successfully

Flags

FlagDescription
--dry-runValidate only, don't deploy. Calls the validate endpoint instead of deploying.
--skip-local-validationSkip local checks, let the server validate

Dry run example

cloudsignal acl update policy.json --dry-run
Validating 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 rule
cloudsignal acl simulate \
  --user 550e8400-e29b-41d4-a716-446655440000 \
  --topic "org/admin/config" \
  --action subscribe
✗ DENIED
  Reason: No matching rule found, default policy is deny

Required flags

FlagDescription
--user <uuid>MQTT user UUID
--topic <topic>MQTT topic to test
--action <publish|subscribe>Action to simulate

Optional flags

FlagDescriptionDefault
--qos <0|1|2>QoS level0

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

This gives you version control over your ACL policies - commit policy.json to git and track changes over time.

On this page