diff --git a/docs/core/authorization.md b/docs/core/authorization.md new file mode 100644 index 0000000..3572d40 --- /dev/null +++ b/docs/core/authorization.md @@ -0,0 +1,282 @@ +--- +sidebar_position: 4 +title: Authorization (FGA) +--- + +# Authorization (Fine-Grained) + +Authorizer ships a built-in fine-grained authorization (FGA) engine alongside its authentication features. FGA is **opt-in per request** and **always enforcing** — a request that asks for a permission the policy graph does not grant is rejected with `unauthorized`. + +This page covers: + +1. The data model — **resources, scopes, policies, permissions**. +2. How a caller asserts a permission via `required_permissions` on `session`, `validate_session`, and `validate_jwt_token`. +3. How an admin defines the policy graph via the `_add_resource` / `_add_scope` / `_add_policy` / `_add_permission` GraphQL mutations. +4. How a client reads its own granted permissions via the `my_permissions` query. +5. Decision strategies, principal targets, and operational observability. + +--- + +## 1. Model + +| Concept | Purpose | Example | +| --- | --- | --- | +| **Resource** | A noun the application protects. | `docs`, `billing`, `org` | +| **Scope** | A verb / action on a resource. | `read`, `write`, `admin` | +| **Policy** | A rule that says **who** matches — a principal selector. Targets a role, a user ID, or an attribute. | "all users with role=`user`" | +| **Permission** | The binding `(resource, [scopes], [policies], decision_strategy)`. Allows scopes on the resource when at least one policy matches (per decision strategy). | "policy `user-role-can-read` grants `docs:read`" | +| **Principal** | The caller being checked. `{id, type, roles, max_scopes?}`. `type` is `user`, `client`, or `agent`. `max_scopes` (optional) is a ceiling — even if a policy grants more, scopes outside `max_scopes` are denied. | `{id: "u-1", type: "user", roles: ["user"]}` | + +**Evaluator contract:** `CheckPermission(principal, resource, scope) → {allowed, matched_policy}`. + +- If no permission row exists for `(resource, scope)`, the result is **deny**. No policy is consulted. +- If permissions exist, each is evaluated via its `decision_strategy` (see §6). An explicit deny short-circuits the request unless overridden by strategy. +- Errors (DB, invalid input) **always fail closed** — the caller sees `unauthorized`. + +--- + +## 2. Asserting permissions on session APIs + +Three GraphQL operations accept an optional `required_permissions: [PermissionInput!]`: + +| Operation | Use case | +| --- | --- | +| `session` | SSO bootstrap. Returns `access_token` only if the cookie's user has every listed permission. Rotates the session cookie on success. | +| `validate_session` | Server-rendered apps with cookies. Validates the cookie **and** the permission set. Does not rotate. | +| `validate_jwt_token` | API gateway / service middleware. Validates a JWT **and** the permission set. Does not rotate. | + +**Input shape:** + +```graphql +input PermissionInput { + resource: String! + scope: String! +} +``` + +Semantics: every entry in `required_permissions` must be allowed (AND). Any deny — or any unknown `(resource, scope)` pair — returns `unauthorized`. + +### Examples + +```graphql +# session +query { + session(params: { + required_permissions: [ + { resource: "docs", scope: "read" } + ] + }) { + access_token + user { id email roles } + } +} + +# validate_jwt_token — multiple required permissions are ANDed +query { + validate_jwt_token(params: { + token_type: "access_token", + token: "", + required_permissions: [ + { resource: "docs", scope: "read" }, + { resource: "billing", scope: "view" } + ] + }) { is_valid claims } +} + +# validate_session +query { + validate_session(params: { + cookie: "", + required_permissions: [ + { resource: "docs", scope: "write" } + ] + }) { is_valid user { id roles } } +} +``` + +Omit `required_permissions` to preserve pre-FGA behavior — the call returns/validates as before. + +--- + +## 3. Building the policy graph (admin mutations) + +All admin mutations require the super-admin secret (cookie or `X-Authorizer-Admin-Secret`). They are prefixed with `_` per Authorizer convention. + +### Step 1 — Define resources and scopes + +```graphql +mutation { _add_resource(params: { name: "docs" }) { id name } } +mutation { _add_scope(params: { name: "read" }) { id name } } +mutation { _add_scope(params: { name: "write" }) { id name } } +``` + +List, update, and delete each have symmetric mutations: `_list_resources`, `_update_resource`, `_delete_resource`, and the same set for `scope`. + +### Step 2 — Define a policy (who matches) + +A policy is a principal selector. The `type` field controls which target is honored: + +| `type` | `target_type` accepts | Notes | +| ----------- | -------------------- | ----- | +| `role` | `role` | `target_value` must be a configured role (see `--roles`). | +| `user` | `user` | `target_value` is the user's **ID** (not email). | +| `attribute` | `attribute` | Custom attribute match — `target_value` is the JSON key the principal must satisfy. | + +```graphql +mutation { + _add_policy(params: { + name: "user-role-can-read", + type: "role", + targets: [{ target_type: "role", target_value: "user" }] + }) { id } +} +``` + +### Step 3 — Bind it all together with a permission + +```graphql +mutation { + _add_permission(params: { + name: "docs-read", + resource_id: "", + scope_ids: [""], + policy_ids: [""], + decision_strategy: "affirmative" + }) { id } +} +``` + +`scope_ids` can include multiple scopes — one permission row can cover `read` + `write`. `policy_ids` likewise can include multiple policies; their combination follows `decision_strategy` (see §6). + +--- + +## 4. Reading granted permissions — `my_permissions` + +A signed-in caller can ask "what am I allowed to do?" without enumerating every `(resource, scope)` pair: + +```graphql +query { + my_permissions { + resource + scope + } +} +``` + +Returns the flat list of `(resource, scope)` pairs granted to the caller's principal. Useful for: + +- Building UIs that hide/show actions based on the current user. +- JWT embedding — bake the list into a custom claim if you want a stateless authz check downstream. + +--- + +## 5. Principal types + +`CheckPermission` evaluates against a `Principal`. Authorizer derives the principal automatically from the calling identity: + +| Auth method | `principal.type` | `principal.id` | +| ----------- | ---------------- | -------------- | +| User session / JWT | `user` | user's UUID | +| Machine-to-machine client credentials | `client` | client ID | +| Agent token (planned) | `agent` | agent ID | + +`max_scopes` is an optional **delegation ceiling** carried on the principal — e.g. a downstream token issued via OAuth's `scope=` param can be ceilinged so it never exceeds the granted set even if policies later widen. + +--- + +## 6. Decision strategies + +A permission can attach multiple policies. Their verdicts combine via `decision_strategy`: + +| Strategy | Semantics | When to use | +| -------- | --------- | ----------- | +| `affirmative` (default) | Any policy granting access wins; deny only if all deny. | Most-permissive — additive role grants. | +| `consensus` | More grants than denies → allow. Equal split → deny. | Voting-style approval. | +| `unanimous` | All policies must grant; any deny denies. | Strict — e.g. "billing-admin AND on-call". | + +An **explicit deny** from any policy in `unanimous` or `consensus` short-circuits to deny. + +--- + +## 7. Observability + +Two Prometheus counters surface authorization behavior. Detailed shapes live in [Metrics & Monitoring](./metrics-monitoring#authorization-metrics). + +| Counter | What it measures | +| ------- | ---------------- | +| `authorizer_required_permissions_checks_total{endpoint, outcome}` | Per-endpoint outcome of `required_permissions`: `granted`, `denied`, `not_requested`, `error`. **Use this for FGA adoption + denial alerting.** | +| `authorizer_authz_checks_total{result}` | Per-`CheckPermission` evaluator outcome: `allowed`, `denied`, `unmatched`, `error`. Lower-level than the above. | +| `authorizer_authz_unmatched_total` | Subset of evaluator calls that found no permission row for `(resource, scope)`. Watch this when adding new `required_permissions` call sites to find gaps in your policy graph. | + +`outcome="error"` on `authorizer_required_permissions_checks_total` is an operational signal — a DB/storage failure is preventing the check from completing. Page on it. + +--- + +## 8. Caching + +`CheckPermission` results are cached for `--authorization-cache-ttl` seconds (default `300`, set `0` to disable). The cache is delegated to your configured `memory_store` — Redis when `--redis-url` is set, the database when only `--database-type` is configured, an in-process fallback otherwise. + +Cache is invalidated automatically when an admin mutation changes any resource, scope, policy, or permission. There is no per-request cache bypass. + +--- + +## 9. Common patterns + +### Gating an API gateway route + +Use `validate_jwt_token` from your gateway middleware: + +```graphql +query { + validate_jwt_token(params: { + token_type: "access_token", + token: "", + required_permissions: [{ resource: "billing", scope: "view" }] + }) { is_valid } +} +``` + +Cache the result for the JWT's remaining lifetime. The server already caches the underlying evaluator result for `--authorization-cache-ttl`; an extra layer at the gateway saves the network hop. + +### Server-rendered app with cookies + +Use `validate_session` on each protected page render: + +```graphql +query { + validate_session(params: { + cookie: "", + required_permissions: [{ resource: "admin", scope: "view" }] + }) { is_valid user { id roles } } +} +``` + +### Bootstrapping SSO with a permission gate + +`session` mints a fresh access token but only when the policy graph allows the listed permissions: + +```graphql +query { + session(params: { + required_permissions: [{ resource: "dashboard", scope: "view" }] + }) { + access_token + user { id } + } +} +``` + +--- + +## 10. Adopting FGA in an existing deployment + +FGA is opt-in per call. Existing callers that don't pass `required_permissions` see no behavior change. + +To roll it out: + +1. **Define the policy graph first.** Add resources, scopes, policies, and permissions via the dashboard (or the admin GraphQL mutations above) before any caller starts asserting them. Any `required_permissions` pointing at an undefined `(resource, scope)` returns `unauthorized` immediately — there is no permissive "log but allow" fallback. +2. **Adopt incrementally.** Add `required_permissions` to one endpoint at a time. Watch `authorizer_required_permissions_checks_total{endpoint, outcome}` per endpoint: + - `outcome="not_requested"` falling = adoption rising. + - `outcome="denied"` rising = policy gap or attacker probe. + - `outcome="error"` non-zero = page; storage / validation failure. +3. **Build the dashboards.** See [Metrics & Monitoring §Authorization Metrics](./metrics-monitoring#authorization-metrics) for PromQL examples. diff --git a/docs/core/graphql-api.md b/docs/core/graphql-api.md index 58851ac..cead63d 100644 --- a/docs/core/graphql-api.md +++ b/docs/core/graphql-api.md @@ -109,10 +109,11 @@ This query can take a optional input `params` of type `SessionQueryInput` which **Request Params** -| Key | Description | Required | -| ------- | ------------------------------------------------------------------------------------------- | -------- | -| `roles` | Array of string with valid roles | false | -| `scope` | List of openID scopes. If not present default scopes ['openid', 'email', 'profile'] is used | false | +| Key | Description | Required | +| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | +| `roles` | Array of string with valid roles | false | +| `scope` | List of openID scopes. If not present default scopes ['openid', 'email', 'profile'] is used | false | +| `required_permissions` | Array of `{resource, scope}` pairs evaluated with AND semantics against the caller's principal. Any deny or unmatched pair returns `unauthorized`. See [Authorization (FGA)](./authorization). | false | It returns `AuthResponse` type with the following keys. @@ -133,7 +134,12 @@ It returns `AuthResponse` type with the following keys. ```graphql query { - session(params: { roles: ["admin"] }) { + session(params: { + roles: ["admin"], + required_permissions: [ + { resource: "dashboard", scope: "view" } + ] + }) { message access_token expires_in @@ -194,11 +200,12 @@ query { Query to validate the given jwt token. This query needs input `params` of type `ValidateJWTTokenInput` **Request Parameters** -| Key | Description | Required | -| ------------ | -------------------------------------------------------------------------------------------------------- | -------- | -| `token_type` | Type of token that needs to be validated. It can be one of `access_token`, `refresh_token` or `id_token` | `true` | -| `token` | Jwt token string | `true` | -| `roles` | Array of roles to validate jwt token for | `false` | +| Key | Description | Required | +| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | +| `token_type` | Type of token that needs to be validated. One of `access_token`, `refresh_token`, `id_token`. | `true` | +| `token` | JWT string | `true` | +| `roles` | Array of roles to validate the JWT token for | `false` | +| `required_permissions` | Array of `{resource, scope}` pairs evaluated with AND semantics against the JWT's principal. Any deny or unmatched pair returns `unauthorized`. See [Authorization (FGA)](./authorization). | `false` | It returns `ValidateJWTTokenResponse` type with the following keys. @@ -213,10 +220,15 @@ It returns `ValidateJWTTokenResponse` type with the following keys. ```graphql query { - validate_jwt_token( - params: { token_type: "access_token", token: "some jwt token" } - ) { + validate_jwt_token(params: { + token_type: "access_token", + token: "some jwt token", + required_permissions: [ + { resource: "docs", scope: "read" } + ] + }) { is_valid + claims } } ``` @@ -226,10 +238,11 @@ query { Query to validate the browser session. This query needs input `params` of type `ValidateSessionInput` **Request Parameters** -| Key | Description | Required | -| ------------ | -------------------------------------------------------------------------------------------------------- | -------- | -| `cookie` | Browser cookie. Either browser http cookie is present or this parameter should be present | `false` | -| `roles` | Array of roles to validate session for | `false` | +| Key | Description | Required | +| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | +| `cookie` | Browser cookie. Either the browser HTTP cookie is present or this parameter must be supplied. | `false` | +| `roles` | Array of roles to validate session for | `false` | +| `required_permissions` | Array of `{resource, scope}` pairs evaluated with AND semantics against the cookie's principal. Any deny or unmatched pair returns `unauthorized`. See [Authorization (FGA)](./authorization). | `false` | It returns `ValidateSessionResponse` type with the following keys. @@ -243,12 +256,41 @@ It returns `ValidateSessionResponse` type with the following keys. ```graphql query { - validate_session(params: { cookie: "" }) { + validate_session(params: { + cookie: "", + required_permissions: [ + { resource: "docs", scope: "write" } + ] + }) { is_valid } } ``` +### `my_permissions` + +Query the flat list of `(resource, scope)` pairs the calling principal has been granted. Requires a valid session or bearer token. + +**Response** + +| Key | Description | +| ---------- | ---------------------------------------- | +| `resource` | Resource name granted to the principal. | +| `scope` | Scope name granted on that resource. | + +**Sample Query** + +```graphql +query { + my_permissions { + resource + scope + } +} +``` + +See [Authorization (FGA)](./authorization) for the full model. + ### `_user` Query to get a specific user by either id or email. @@ -1628,3 +1670,61 @@ mutation { } } ``` + +### Authorization (admin) + +Manage the FGA policy graph. All require super-admin authentication (cookie or `X-Authorizer-Admin-Secret`). See [Authorization (FGA)](./authorization) for the conceptual model. + +#### `_add_resource` / `_update_resource` / `_delete_resource` / `_list_resources` + +Manage **resources** (the nouns the application protects). + +```graphql +mutation { _add_resource(params: { name: "docs" }) { id name } } +mutation { _update_resource(params: { id: "", name: "documents" }) { id name } } +mutation { _delete_resource(params: { id: "" }) { message } } +query { _list_resources(params: { pagination: { limit: 25, page: 1 } }) { + pagination { total limit page } + resources { id name } +} } +``` + +#### `_add_scope` / `_update_scope` / `_delete_scope` / `_list_scopes` + +Manage **scopes** (verbs / actions). Same input/output shape as resources. + +#### `_add_policy` / `_update_policy` / `_delete_policy` / `_list_policies` + +Manage **policies** (principal selectors). + +```graphql +mutation { + _add_policy(params: { + name: "user-role-can-read", + type: "role", + targets: [{ target_type: "role", target_value: "user" }], + logic: "positive", + decision_strategy: "affirmative" + }) { id name } +} +``` + +`type` accepts `role`, `user`, or `attribute`. `target_value` for `role` policies must be a configured role (see `--roles`). `target_value` for `user` policies is the user's **ID** (not email). + +#### `_add_permission` / `_update_permission` / `_delete_permission` / `_list_permissions` + +Bind a resource + scopes + policies into a single permission row. + +```graphql +mutation { + _add_permission(params: { + name: "docs-read", + resource_id: "", + scope_ids: [""], + policy_ids: [""], + decision_strategy: "affirmative" + }) { id name } +} +``` + +`decision_strategy` is one of `affirmative` (default), `consensus`, or `unanimous`. See [Authorization §6](./authorization#6-decision-strategies). diff --git a/docs/core/metrics-monitoring.md b/docs/core/metrics-monitoring.md index daeab27..336fd57 100644 --- a/docs/core/metrics-monitoring.md +++ b/docs/core/metrics-monitoring.md @@ -132,6 +132,42 @@ raised. Alert at the rate that distinguishes the two for your traffic profile. See [GraphQL hardening](./security#graphql-hardening) for the limits themselves. +### Authorization Metrics + +| Metric | Type | Labels | Description | +|--------|------|--------|-------------| +| `authorizer_required_permissions_checks_total` | Counter | `endpoint`, `outcome` | Per-endpoint outcome of `required_permissions` on session APIs. | +| `authorizer_authz_checks_total` | Counter | `result` | Every `CheckPermission` call. `result=allowed\|denied\|unmatched\|error`. | +| `authorizer_authz_unmatched_total` | Counter | — | `CheckPermission` calls that found no permission row for `(resource, scope)`. | +| `authorizer_authz_check_duration_seconds` | Histogram | — | End-to-end `CheckPermission` latency. | + +**`required_permissions_checks_total` labels:** + +| Label | Values | +| ----- | ------ | +| `endpoint` | `session`, `validate_session`, `validate_jwt_token` | +| `outcome` | `granted` (all listed permissions allowed) · `denied` (one or more denied) · `not_requested` (caller omitted the field) · `error` (CheckPermission errored — DB/validation) | + +**Use cases:** + +- `outcome="denied"` rising on a given endpoint = either a policy gap or an attacker probe. Cross-check with `authorizer_authz_unmatched_total` (gap) versus `authorizer_authz_checks_total{result="denied"}` (policy deny). +- `outcome="error"` should sit at zero. Any non-zero rate is an infra problem — alert on it. +- `outcome="not_requested"` is the FGA *adoption gap* — share of calls not yet opting into permission gating. + +```promql +# Adoption: share of calls per endpoint that still don't pass required_permissions +sum by (endpoint) (rate(authorizer_required_permissions_checks_total{outcome="not_requested"}[5m])) + / +sum by (endpoint) (rate(authorizer_required_permissions_checks_total[5m])) +``` + +```promql +# Alert candidate: required_permissions errors over 5 minutes +sum(rate(authorizer_required_permissions_checks_total{outcome="error"}[5m])) > 0 +``` + +See [Authorization (FGA)](./authorization) for the underlying model. + ### Infrastructure Metrics | Metric | Type | Labels | Description | @@ -249,6 +285,15 @@ groups: severity: warning annotations: summary: "Elevated GraphQL error rate" + + - alert: AuthzRequiredPermissionsErrors + expr: sum(rate(authorizer_required_permissions_checks_total{outcome="error"}[5m])) > 0 + for: 5m + labels: + severity: page + annotations: + summary: "required_permissions checks are failing with errors" + description: "Authorizer's FGA evaluator is returning errors on required_permissions checks. Storage or validation failure is likely. Inspect server logs." ``` ## Manual Testing diff --git a/docs/core/security.md b/docs/core/security.md index b63f2f9..3bd6659 100644 --- a/docs/core/security.md +++ b/docs/core/security.md @@ -424,9 +424,16 @@ This kills the user-enumeration attack surface entirely. --- +## Fine-grained authorization + +Authorizer ships a built-in FGA engine that is **always enforcing** — a `required_permissions` check against an unmatched or denied `(resource, scope)` pair returns `unauthorized`. There is no permissive "log but allow" mode. See [Authorization (FGA)](./authorization) for the data model, admin mutations, and per-endpoint usage. + +--- + ## See also - [Server Configuration](./server-config) — full CLI flag reference +- [Authorization (FGA)](./authorization) — resources, scopes, policies, permissions - [Rate Limiting](./rate-limiting) — rate limiter configuration - [Metrics & Monitoring](./metrics-monitoring) — Prometheus metrics including the new GraphQL limit counter - [v1 to v2 Migration](../migration/v1-to-v2) — for users upgrading from v1 diff --git a/docs/core/server-config.md b/docs/core/server-config.md index e55d0ba..e4a321c 100644 --- a/docs/core/server-config.md +++ b/docs/core/server-config.md @@ -276,6 +276,21 @@ counted in the `authorizer_graphql_limit_rejections_total` Prometheus metric, labelled by limit kind. See [GraphQL hardening](./security#graphql-hardening) for details. +### Authorization (FGA) + +```bash +./build/server \ + --authorization-cache-ttl=300 \ + --include-permissions-in-token=false \ + --authorization-log-all-checks=false +``` + +- **`--authorization-cache-ttl`** (default `300`): per-`CheckPermission` cache time-to-live in seconds. Set `0` to disable the cache. The cache is delegated to your configured `memory_store` — Redis when `--redis-url` is set, the database when only `--database-type` is configured, an in-process fallback otherwise. Cache is invalidated automatically when an admin mutation changes any resource, scope, policy, or permission. +- **`--include-permissions-in-token`** (default `false`): when true, the access token's claims include the caller's flat `(resource, scope)` grant list. Useful for stateless downstream services that don't want to round-trip back to Authorizer per check. +- **`--authorization-log-all-checks`** (default `false`): audit-log every `CheckPermission` call, not just denials. Diagnostic; expensive at scale. + +Authorization is always enforcing — a `required_permissions` check against an unmatched or denied `(resource, scope)` pair returns `unauthorized`. There is no permissive mode. See [Authorization (FGA)](./authorization) for the full model. + --- ## 9. Security headers diff --git a/docs/migration/v1-to-v2.md b/docs/migration/v1-to-v2.md index 90e5fd0..2b9e60d 100644 --- a/docs/migration/v1-to-v2.md +++ b/docs/migration/v1-to-v2.md @@ -504,3 +504,26 @@ import { SignUpRequest, LoginRequest } from '@authorizerdev/authorizer-js' - [ ] Upgrade **@authorizerdev/authorizer-js** to v3 and **@authorizerdev/authorizer-react** to v2; update type names and Node version as needed. - [ ] Use **kebab-case** flags (for example `--database-url`) and avoid deprecated names (`database_url`, `env_file`, etc.). - [ ] Re-test admin login, JWT issuance, and any flows that previously depended on dashboard-updated env. + +--- + +## Authorization (FGA) + +v2 introduces a fine-grained authorization layer alongside the existing role check. It is **opt-in per request** — pre-v2 callers that do not pass `required_permissions` see no behavior change. + +### What's new + +- `session`, `validate_session`, and `validate_jwt_token` accept a new optional `required_permissions: [PermissionInput!]` field. Any deny or unmatched `(resource, scope)` returns `unauthorized`. +- Admin GraphQL mutations: `_add_resource`, `_add_scope`, `_add_policy`, `_add_permission` (plus list / update / delete for each). Dashboard UI under Authorization → Resources / Scopes / Policies / Permissions. +- New per-call `my_permissions` query returns the flat `(resource, scope)` list granted to the calling principal. +- New CLI flag `--authorization-cache-ttl` (default `300` seconds). Cache is delegated to your configured `memory_store` (Redis or DB-backed); set `0` to disable. +- New Prometheus counter `authorizer_required_permissions_checks_total{endpoint, outcome}` for adoption + denial tracking. Outcomes: `granted` / `denied` / `not_requested` / `error`. + +### Adoption checklist + +- [ ] **Define the policy graph first** via the dashboard (Authorization → Resources/Scopes/Policies/Permissions) or admin GraphQL mutations. Any `required_permissions` pointing at an undefined `(resource, scope)` returns `unauthorized` immediately — authorization is always enforcing, there is no permissive fallthrough. +- [ ] **Adopt incrementally** by adding `required_permissions` to one session API call site at a time. +- [ ] **Alert on `outcome="error"`** for `authorizer_required_permissions_checks_total` — should sit at zero. +- [ ] **Track adoption** via `outcome="not_requested"` per endpoint. + +Full reference: [Authorization (FGA)](../core/authorization). diff --git a/sidebars.ts b/sidebars.ts index 566eaa3..6faf9bb 100644 --- a/sidebars.ts +++ b/sidebars.ts @@ -18,6 +18,7 @@ const sidebars: SidebarsConfig = { 'core/index', 'core/server-config', 'core/security', + 'core/authorization', 'core/databases', 'core/endpoints', 'core/graphql-api',