# Write IAM Policies

Below is a collection of best practices and examples for creating effective policies to control the authorization of users and API keys at Exoscale.


## Rule Based Authorization (RBA) 101

```json
{
  "default-service-strategy": "deny",
  "services": {
    "compute": {
      "type": "rules",
      "rules": [
        {
          "action": "deny",
          "expression": "resource.sks_nodepool.name in ['important-nodepool', 'foobar']"
        },
        {
          "action": "allow",
          "expression": "true"
        }
      ]
    }
  }
}
```

A rule-based service policy consists of a list of one or more rules:

- Rules are composed of an **Expression** - against which the request is evaluated, and an **Action** - the resulting *decision* should the evaluated expression returns *TRUE*.
- Expressions are written in the [Common Expression Language CEL](https://github.com/google/cel-spec)
- Rules are evaluated in order
- Operator precedence is defined in the [CEL language specification](https://github.com/google/cel-spec/blob/master/doc/langdef.md#syntax)
- If the `expression` of a rule is **valid**, its `action` becomes the authorization output - `allow` or `deny`.
- If the `expression` of a rule is **invalid**, nothing is concluded and we evaluate the **next rule**
- If **no rule is valid**, the request is not authorized to proceed *regardless of the default service strategy*.

The authorization flow will process each rule in order. The first **valid** rule will short-circuit the authorization process, resulting in its action (either **allow** or **deny**) - equivalent to an OR condition between rules. If all rules are exhausted the request is rejected, regardless of the `default service strategy`.

## CEL bindings

A binding refers to the coupling of a variable in a CEL expression and its corresponding value at the time the expression is evaluated.

The top-level bindings are:

- `service` - the service class in scope (ex: “sos”, “instance-pool”, etc)
- `zone` - the zone where the call is attempted (ex: “ch-gva-2”)
- `now` - a CEL timestamp (string, can be coerced to CEL timestamp in expressions via timestamp(now))
- `source_ip` - the ip of the caller
- `api_key` - the exoscale api key of the caller `EXO1234...`
- `operation` - the operation performed (ex: “scale-instance-pool”)
- `identity` - the caller's identity. A map containing the following keys:
    - `identity.key` - the caller's exoscale API key `EXO1234...`
    - `identity.created` - the CEL timestamp of the key's creation
    - `identity.description` - the key's description (name)
    - `identity.org.uuid` - the caller's organization UUID
    - `identity.org.name` - the caller's organization name
- `parameters` - parameters passed to the command (ex: `parameters.size -> 3`)
- `headers` - relevant for SOS; a map containing the following keys, when applicable:
  - `headers['if-match']` - present when an `if-match` conditional request was sent by the client 
  - `headers['if-none-match']` - present when an `if-none-match` conditional request was sent by the client
  - `headers['sse-c-customer-algorithm']` - present when the SOS request is sent with customer encryption keys, the value will be `AES256`
- `resources` - a map of resource type -> resource (varies depending on the call, see 
[IAM References]({{< ref "/product/security/iam/reference/" >}})

The `resources` and `parameters` bindings are special, as they don't resolve to mere literals, they can contain nested bindings.
The key difference between `parameters` and `resources` is that the latter might contain metadata that is not in the request payload.

For example:

- `parameters.foo == 'bar'` restrict the call if the input `foo` is equal to `bar`.
- `parameters.foo.bar == 'baz'` or `parameters.foo == {'bar':'baz'}` - restrict the call if the input object `foo` has a key `bar` equal to `baz`
- `parameters.foo.exists(k, k.bar == 'baz')` - restrict the call if the input collection `foo` has and object with a key `bar` equals to `baz`
- `resources.elastic_ip.ip == 10.10.10.10` - restrict the call on an `elastic_ip` resource that has the specified *IP* address.
- `resources.instance_pool.id == "d4c1673a-a342-4a0f-b8e7-b2da4091ddfd"` - restrict the call on an `instance_pool` that has the specified *ID*.

The [IAM References]({{< ref "/product/security/iam/reference/" >}}) contains the necessary information to map any API endpoint to its corresponding `operation`, `parameters`, and `resources` bindings.

## CEL Best Practices


### Split expressions into rules

When writing a rule with more than one condition, it becomes more manageable to write several smaller rules.

This can be achieved as long as the conditions are separated by OR operands.

```json
[
  {
    "action": "allow",
    "expression": "operation == 'list-buckets' || resources.bucket.startsWith('public-')"
  }
]
```

is equivalent to

```json
[
  {
    "action": "allow",
    "expression": "operation == 'list-buckets'"
  },
  {
    "action": "allow",
    "expression": "resources.bucket.startsWith('public-')"
  }
]
```

### Write a catch-all rule

Sometimes, we want our service to have a different default behavior than the rest, while still abiding to some rules.

Because rules are evaluated in order, we can achieve this by writing a catch-all rule at the end of the list.

This is also useful to prevent unexpected authorization failures due to no rule matching the request, it is particularly useful when writing organization policies where you may wish to prevent certain calls across the organization (such as deleting an instance with a particular label, or modifying a particular security group):

```json
{
  "default-service-strategy": "allow",
  "services": {
    "compute": {
      "type": "rules",
      "rules": [
        {
          "action": "deny",
          "expression": "'foo' in resource.sks_cluster.addons"
        },
        {
          "action": "allow",
          "expression": "true"
        }
      ]
    }
  }
}
```

### Don't mix resources in a single rule

Every request may load a specific number of resources in order to have enough information to evaluate the rule properly. This means that not every rule will be able to evaluate every request.

For example: the expression `"resource.security-group.name == 'dev-sg'"` assumes the presence of a security group resource has been loaded into the context.

Consider the incoming request is `GET /instance-pool/<id>` (listing instance pools). Both instance pools and security groups belong to the `compute` service, thus the aforementioned CEL expression will be evaluated. However, only the instance pool resource is loaded for `get-instance-pool` so a CEL expression that refers to `resources.instance_pool.id` and `resources.security_group.id` cannot result in a match for the `get-instance-pool` endpoint.

Even if the expression includes an instance pool parameter `"resource.security_group.name == 'dev-sg' && size(resource.instancepool.instances) > 2"`, it will be invalid too.

Therefore we strongly recommended to write separate rules based on the resource types, and to match on the operation or a list of operations when checking resource rules.


### CEL extensions

We currently have the following CEL functions added as extensions:

* `inIpRange(<string IP>, <string Range>)`: returns true if `IP` is within `Range`.

```js
source_ip.inIpRange('127.0.0/24')
```

```js
inIpRange(source_ip, '127.0.0/24')
```

```js
resources.instance.ipv6_address.inIpRange('2001:0db8:85a3:0000:0000:0000:0000:0000/64')
```

## Compute

Allow only requests that load an `instance` resource - eg. [the `resize-instance-disk` operation](/reference/iam/compute/#resize-instance-disk) - whose labels include `"dev"`.

```json
{
  "default-service-strategy": "allow",
  "services": {
    "compute": {
      "type": "rules",
      "rules": [
        {
          "action": "allow",
          "expression": "!has(resources.instance)"
        },
        {
          "action": "allow",
          "expression": "'dev' in resources.instance.labels"
        }
      ]
    }
  }
}
```

The first rule `!has(resources.instance)` is needed because the second rule will fail for any request that doesn't involve an instance, thus denying authorization.

> [!WARNING]
> A lookup on a non-existing binding will trigger an error within the CEL evaluator and short-circuit to a 
> failed expression. The use of `has(some.binding.foo)` is very useful to avoid this situation.


Allow read only operations:

```json
operation.startsWith('get-') || operation.startsWith('list-')
```

Allow an instance pool to be scaled to a size of 2, 3, or 4.

```json
operation == 'scale-instance-pool' && int(parameters.size) >= 2 && int(parameters.size) <= 4
```


Prevent the deletion of the load balancer with the name `my-nlb`:

```json
operation == 'delete-load-balancer' && resources.load_balancer.name == 'my-nlb'
```


Prevent the deletion of nodepools on the SKS cluster named `my-sks-cluster`:

```json
operation == 'delete-sks-nodepool' && resources.sks_cluster.name == 'my-sks-cluster'
```


Only allow calls to create instance where `public_ip_assignment` has been specified and where the `public_ip_assignment` is set to `none`.

```json
{
  "default-service-strategy": "allow",
  "services": {
    "compute": {
      "type": "rules",
      "rules": [
        {
          "action": "deny",
          "expression": "operation == 'create-instance' && (!parameters.has('public_ip_assignment') || parameters.public_ip_assignment != 'none')"
        },
        {
          "action": "allow",
          "expression": "true"
        }
      ]
    }
  }
}
```


Prevent the creation and modification of resources in the zone `ch-dk-2`, this could also be written with the simpler rule `zone == 'ch-dk-2'` but errors will be visible on some tooling such as the UI which shows resources across all zones - so allowing read only calls can be useful:

```json
{
  "default-service-strategy": "allow",
  "services": {
    "compute": {
      "type": "rules",
      "rules": [
        {
          "expression": "zone == 'ch-dk-2' && !(operation.startsWith('get-') || operation.startsWith('list-'))",
          "action": "deny"
        },
        {
          "expression": "true",
          "action": "allow"
        }
      ]
    }
  }
}
```

Allow/deny calls from a given source IP:

```json
source_ip == '188.61.116.99'
```

Allow/deny calls from a list of source IPs:

```json
source_ip in ['188.61.126.88', '188.61.116.99']
```

Allow only Snapshot creation and export, and no deletion:

```json
{
  "default-service-strategy": "deny",
  "services": {
    "compute": {
      "type": "rules",
      "rules": [
        {
          "expression": "operation in ['create-snapshot', 'export-snapshot']",
          "action": "allow"
        },
        {
          "expression": "operation.startsWith('get-') || operation.startsWith('list-')",
          "action": "allow"
        }
      ]
    }
  }
}
```

Allow creation of [Private Instances]({{< ref "/product/compute/instances/overview/" >}}) only:

```json
{
  "default-service-strategy": "deny",
  "services": {
    "compute": {
      "type": "rules",
      "rules": [
        {
          "expression": "operation in ['create-instance','create-instance-pool'] && (!parameters.has('public_ip_assignment') || parameters.public_ip_assignment != 'none')",
          "action": "deny"
        },
        {
          "expression": "true",
          "action": "allow"
        }
      ]
    }
  }
}
```

Allow reboot only of any Compute Instance:

```json
{
  "default-service-strategy": "deny",
  "services": {
    "compute": {
      "type": "rules",
      "rules": [
        {
          "expression": "operation.startsWith('get-')",
          "action": "allow"
        },
        {
          "expression": "operation.startsWith('list-')",
          "action": "allow"
        },
        {
          "expression": "operation in ['reboot-instance']",
          "action": "allow"
        }
      ]
    }
  }
}
```

Allow read-only operations on any Compute Instance:

```json
{
  "default-service-strategy": "deny",
  "services": {
    "compute": {
      "type": "rules",
      "rules": [
        {
          "expression": "operation.startsWith('get-')",
          "action": "allow"
        },
        {
          "expression": "operation.startsWith('list-')",
          "action": "allow"
        }
      ]
    }
  }
}
```

List/query Audit-Trail Events but restrict any other operation:

```json
{
  "default-service-strategy": "allow",
  "services": {
    "compute": {
      "type": "rules",
      "rules": [
        {
          "expression": "operation == 'list-events'",
          "action": "allow"
        }
      ]
    }
  }
}
```

## DNS

Allow the retrieval of domains and records:

```json
operation in ['list-dns-domains', 'get-dns-domain', 'list-dns-domain-records', 'get-dns-domain-record']
```

Allow the creation of domains that end with `.ch`:

```json
operation == 'create-dns-domain' && parameters.unicode_name.endsWith('.ch')
```

Allow the creation of `TXT` records only (on any domain):

```json
operation == 'create-dns-domain-record' && parameters.type == 'TXT'
```

Allow `TXT` records to be updated and deleted:

```json
operation in ['update-dns-domain-record', 'delete-dns-domain-record'] && resources.dns_domain_record.type == 'TXT'
```

Allow the creation of `A` records on the domain `my-test-domain.ch`:

```json
operation == 'create-dns-domain-record' && resources.dns_domain.unicode_name == 'my-test-domain.ch' && parameters.type == 'A'
```

Only allow TTLs in the range of 600 (10 minutes) to 7200 (2 hours):

```json
operation in ['create-dns-domain-record', 'update-dns-domain-record'] && int(parameters.ttl) < 600 && int(parameters.ttl) > 7200
```


## DBaaS

Prevent the creation of PostgreSQL services that do not specify a single IP filter of `10.20.0.0/16` (note: the order of `deny` rules in a policy is important, as the first rule that matches will either allow or deny the request):

```json
operation == 'create-dbaas-service-pg' && !parameters.ip_filter.exists_one(x, x == '10.20.0.0/16')
```

Allow the creation of PostgreSQL services with the hobbyist-2 or startup-4 plans:

```json
operation == 'create-dbaas-service-pg' && parameters.plan in ['hobbyist-2', 'startup-4']
```

Prevent the creation of dbaas resources in the zone `CH-DK-2`:

```json
operation.startsWith('create-dbaas-') && zone == 'ch-dk-2'
```

Prevent the deletion of dbaas services in the zone `CH-GVA-2`:

```json
operation.startsWith('delete-dbaas-service-') && zone == 'ch-gva-2'
```

Allow the PostgreSQL service `my-service` to be retrieved:

```json
operation == 'get-dbaas-service-pg' && parameters.name == 'my-service'
```

Allow only a specific User or API Key to see his Kafka secrets

```json
operation = 'reveal-dbaas-kafka-user-password' && parameters.username = 'a-user'
```

## IAM

Deny requests to the IAM service. Allow all requests to other services.

```json
{
  "default-service-strategy": "allow",
  "services": {
    "iam": {
      "type": "deny"
    }
  }
}
```

Deny requests to the IAM service for a specific key. Allow all requests to other services regardless of which key is used.

```json
{
  "default-service-strategy": "allow",
  "services": {
    "iam": {
      "type": "rules",
      "rules": [
        {
          "action": "allow",
          "expression": "api_key != 'EXO123456789'"
        }
      ]
    }
  }
}
```

Alternatively this can be written using a deny rule, which is more practical if you want to enforce this within an organization policy:

```json
{
  "default-service-strategy": "allow",
  "services": {
    "iam": {
      "type": "rules",
      "rules": [
        {
          "action": "deny",
          "expression": "api_key == 'EXO123456789'"
        },
        {
          "action": "allow",
          "expression": "true"
        }
      ]
    }
  }
}
```

Ensure keys can only be created for a particular role id:

```json
{
  "default-service-strategy": "allow",
  "services": {
    "iam": {
      "type": "rules",
      "rules": [
        {
          "action": "deny",
          "expression": "operation == 'create-api-key' && parameters.role_id != '<my-role-id uuid>'"
        },
        {
          "action": "allow",
          "expression": "true"
        }
      ]
    }
  }
}
```

Prevent updates and deletion of a role named 'my-role':

```json
operation in ['update-iam-role', 'update-iam-role-policy', 'delete-iam-role'] && resources.iam_role.name == 'my-role'
```

Allow keys to be created, listed, and retrieved:

```json
{
  "default-service-strategy": "allow",
  "services": {
    "iam": {
      "type": "rules",
      "rules": [
        {
          "action": "allow",
          "expression": "operation in ['create-api-key', 'list-api-keys', 'get-api-key']"
        }
      ]
    }
  }
}
```

Create a time-expiring IAM Key limited to the Compute service class (5 minutes in the example):

```json
{
  "default-service-strategy": "deny",
  "services": {
    "compute": {
      "type": "rules",
      "rules": [
        {
          "expression": "timestamp(identity.created) < timestamp(now) - duration('5m')",
          "action": "deny"
        },
        {
          "expression": "true",
          "action": "allow"
        }
      ]
    }
  }
}
```

## Object Storage (SOS)

The following policy allows listing and retrieving objects on the bucket `my-bucket`, no other operations or buckets are allowed meaning that the caller will not be able to list buckets or to look at the properties of a bucket using the `exo storage show` command.

```json
{
  "default-service-strategy": "deny",
  "services": {
    "sos": {
      "type": "rules",
      "rules": [
        {
          "expression": "parameters.bucket == 'my-bucket' && operation in ['list-objects', 'get-object']",
          "action": "allow"
        }
      ]
    }
  }
}
```

A more elaborate policy is provided in the next example:

- Allows listing all buckets in the organization via the Exoscale CLI `exo storage ls` or s3cmd `s3cmd ls`. Other S3 compatible tooling may require additional operations to be added,
- Operations relating to a bucket are restricted, only `my-bucket` and `my-other-bucket` are allowed, attempts to list objects in other buckets will be rejected.
- The last rule in the policy relates to usage via the Exoscale CLI, more operations are needed to support `exo storage show sos://my-bucket`.
- The order of the rules is important due to the way the `deny` rule has been written, in order to allow listing buckets the expression containing `list-buckets` must come before the `deny` expression.

```json
{
  "default-service-strategy": "deny",
  "services": {
    "sos": {
      "type": "rules",
      "rules": [
        {
          "expression": "operation in ['list-sos-buckets-usage', 'list-buckets']",
          "action": "allow"
        },
        {
          "expression": "!(parameters.bucket in ['my-bucket', 'my-other-bucket'])",
          "action": "deny"
        },
        {
          "expression": "operation in ['list-objects', 'get-object']",
          "action": "allow"
        },
        {
          "expression": "operation in ['get-bucket-acl', 'get-bucket-cors', 'get-bucket-ownership-controls']",
          "action": "allow"
        }
      ]
    }
  }
}
```

Allow/deny reading/writing on a specific `public` prefix:

```json
operation in ['get-object', 'put-object'] && parameters.key.startsWith('public')
```

Allow only read operations on a certain bucket:

```json
{
  "default-service-strategy": "deny",
  "services": {
    "sos": {
      "type": "rules",
      "rules": [
        {
          "expression": "resources.bucket != 'YOUR-BUCKET-NAME'",
          "action": "deny"
        },
        {
          "expression": "operation.startsWith('get-')",
          "action": "allow"
        },
        {
          "expression": "operation.startsWith('head-')",
          "action": "allow"
        },
        {
          "expression": "operation.startsWith('list-')",
          "action": "allow"
        }
      ]
    }
  }
}
```

Allow list all organization buckets. Allow list content of bucket `my-bucket` and all sub-folders, allow only read / download in `my-sub-folder`:

```json
{
  "default-service-strategy": "deny",
  "services": {
    "sos": {
      "type": "rules",
      "rules": [
        {
          "expression": "parameters.bucket != 'my-bucket'",
          "action": "deny"
        },
        {
          "expression": "operation.startsWith('get-') && parameters.key.startsWith('my-sub-folder')",
          "action": "allow"
        },
        {
          "expression": "operation.startsWith('head-') && parameters.key.startsWith('my-sub-folder')",
          "action": "allow"
        },
        {
          "expression": "operation.startsWith('list-')",
          "action": "allow"
        }
      ]
    }
  }
}
```

To forbid listing organization buckets. Add this rule as the first rule (in example above):

```json
       {
          "expression": "operation.startsWith('list-') && parameters.bucket == 'my-bucket'",
          "action": "allow"
        },
```

## User management and portal access

Most commonly, you will need an IAM Role giving access to all resources in the web portal excluding:

- user management
- billing management

This policy will be defined as:

```json
{
  "default-service-strategy": "allow",
  "services": {
    "iam": {
      "type": "rules",
      "rules": [
        {
          "expression": "operation in ['create-user', 'delete-user', 'list-users', 'update-user-role']",
          "action": "deny"
        },
        {
          "expression": "true",
          "action": "allow"
        }
      ]
    }
  }
}
```

> [!WARNING]
> In order for this IAM Policy to be effective it's important to mark the role as non-editable.

A more restrictive policy excluding IAM completely:

```json
{
  "default-service-strategy": "allow",
  "services": {
    "iam": {
      "type": "deny"
    }
  }
}
```

A simple policy allowing only the usage of the `Compute` service class:

```json
{
  "default-service-strategy": "deny",
  "services": {
    "compute": {
      "type": "allow"
    }
  }
}
```

For any other case you can refer to the different service class examples in this page.

> [!NOTE]
> If you desire to be more restrictive in your Policy, keep in mind that in order to work properly 
> the web portal and the exo CLI always require access to the following operations:
>
> - [`list-zones`](https://community.exoscale.com/reference/api/general/zone/#list-zones)
> - [`get-operation`](https://community.exoscale.com/reference/api/general/operation/#get-operation)

Without those two operations the web portal cannot function, you should always include them in your Policy if you wish to customize it heavily.

> [!NOTE]
> Including the ones previously mentioned, other calls might be still required for the portal to be able to 
> bootstrap a specific view. The best way to learn about those is to check your web inspector to identify the 
> required calls. Also, the calls might vary over time, as we add more features to the web portal, eventually 
> leading to the need of adapting your policy.


A concrete example of a more restrictive policy giving access only to a single Object Storage Bucket:

```json
{
  "default-service-strategy": "deny",
  "services": {
    "compute": {
      "type": "rules",
      "rules": [
        {
          "expression": "operation in ['list-zones', 'get-operation']",
          "action": "allow"
        },
      ]
    },
    "sos": {
      "type": "rules",
      "rules": [
        {
          "expression": "parameters.bucket == 'my-bucket'",
          "action": "allow"
        }
      ]
    }
  }
}
```

This would let the user operate only on `my-bucket` and nothing else in the web portal. This also means **the user will not be able to "navigate" to the bucket, as we have not allowed other operations as e.g. `list-buckets-usage` that are required for the different views involved in the navigation to be loaded**. You will hence need to share the exact URL to the required view to your user:

`https://portal.exoscale.com/storage/buckets/ch-dk-2/my-bucket/contents/`

## Concrete AI

The following policy allows read-only access to AI resources (models, deployments and API keys).

```json
{
  "default-service-strategy": "deny",
  "services": {
    "ai": {
      "type": "rules",
      "rules": [
        {
          "expression": "operation.startsWith('get-') || operation.startsWith('list-')",
          "action": "allow"
        }
      ]
    }
  }
}
```

A more elaborate policy is provided in the next example. It builds on the previous one by constraining what can be created:

- Read-only access to all AI resources
- Deployment creation is allowed only with at most 2 GPUs and 3 replicas
- Model creation is allowed

```json
{
  "default-service-strategy": "deny",
  "services": {
    "ai": {
      "type": "rules",
      "rules": [
        {
          "expression": "operation.startsWith('get-') || operation.startsWith('list-')",
          "action": "allow"
        },
        {
          "expression": "operation == 'create-deployment' && int(parameters.gpu_count) <= 2 && int(parameters.replicas) <= 3",
          "action": "allow"
        },
        {
          "expression": "operation == 'create-model'",
          "action": "allow"
        }
      ]
    }
  }
}
```

## Understanding a forbidden API call

Here are possible scenarios when the API returns a 403 Forbidden response.

- `forbidden by [role|org] policy, [compute|sos|dns|iam|ai]:
Unable to find an operation in the list defined by the policy`. The message indicates that on the specific policy (at role or organization level), the performed operation is not specified with either `deny` or `allow` action. As the parser is not able to find a single match - the call is denied. The message also shows a service-class the restriction belongs to.

> [!NOTE]
If the goal is to restrict a key to a specific operation or resource, but at the same time allow any other type of operations - `{"action":"allow" "expression":"true"}` could be added to the end of the rules set.


- `forbidden by [role|org] policy, [compute|sos|dns|iam|ai] - A deny rule matched. Rule index: *INDEX_NUMBER*`
This extended version of the previous message, will appear when a policy has rules whose **action** is `deny` and matches the performed call. The index indicates the sequence number of the rule in the rules set starting at 0 for the first rule.

