diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 143a865d..ee671f1d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,83 +1,183 @@ # Contributing to Stackform -Thanks for your interest in the project. This guide covers the development setup, the test suite, -and what a reviewable change looks like. +Thank you for your interest in Stackform. Bug reports, documentation fixes and code changes are all +welcome. This guide explains how to get in touch, how to set up a development environment, and what +a pull request needs before it can be merged. + +For what Stackform does and how to use it, read the [README](README.md) and the +[manual](https://sebastiaoteixeira.github.io/yaml-framework-docs/). + +## Questions, bugs and ideas + +All conversation happens in [GitHub issues](https://github.com/ieeta-pt/stackform/issues). Search +the open and closed issues first; your question or bug may already have an answer. + +- **Questions.** Open an issue and describe what you are trying to do. +- **Bug reports.** Include: + - the Stackform commit or version and your Python version; + - the smallest YAML spec that reproduces the problem, including its `version:` line; + - the exact command you ran (for example `stackform build spec.yaml --target drf`); + - what you expected and what happened, with the full error output. +- **Feature proposals.** Describe the problem first, then the change you suggest. For a change to + the specification language, show the YAML a user would write. + +## Before you write code + +Small fixes such as typos, broken links or an obvious one-line bug can go straight to a pull +request. For anything larger, open an issue first and wait for a maintainer to agree on the +approach. This applies in particular to new spec keys or changes to existing ones, which every +backend target has to support. Issues labelled +[`good first issue`](https://github.com/ieeta-pt/stackform/labels/good%20first%20issue) or +[`help wanted`](https://github.com/ieeta-pt/stackform/labels/help%20wanted) are open for anyone to +take; leave a comment saying you are working on one. ## Development setup -The project is managed with [uv](https://docs.astral.sh/uv/). A locked environment for the -supported interpreters is created with: +You need: + +- Python 3.11 or newer (CI tests 3.11 and 3.13); +- [uv](https://docs.astral.sh/uv/), which installs the locked dependencies; +- Docker, for the integration tests; +- Node.js 18 or newer, only for frontend generation and the tests that run generated frontend code. + +Fork the repository, then clone your fork and install the project with its development +dependencies: ```bash -uv sync --locked --all-extras +git clone https://github.com//stackform.git +cd stackform +uv sync --all-extras ``` -A plain virtual environment works too: +The `stackform` command is now available through `uv run`: ```bash -pip install -e ".[dev]" +uv run stackform --help ``` -Python 3.11 or newer is required. The integration suite additionally needs a MongoDB instance: +## Running the tests + +The suite has two parts. Tests marked `integration` start real services; everything else runs +without them. + +Run the unit suite, as CI does: ```bash -docker run -d --name stackform-mongodb -p 27017:27017 mongo:8 +uv run pytest -m "not integration" ``` -## Running the tests +Run the integration suite: ```bash -uv run pytest -m "not integration" # unit suite, no services required -uv run pytest -m integration # integration suite, needs MongoDB -uv run pytest # everything +uv run pytest -m integration ``` -GitHub Actions runs the unit suite on Python 3.11 and 3.13 for every push to `main` and every pull -request. A pull request whose unit suite is red is not reviewed. +Each integration test starts a throwaway Docker container for the service it needs (`mongo:7`, +`postgres:16` or `minio/minio`). To use a service you already run, set its environment variable +instead: + +| Service | Variable | +| ---------- | ---------------------------- | +| MongoDB | `YAML_API_TEST_MONGO_URI` | +| PostgreSQL | `YAML_API_TEST_POSTGRES_URI` | +| S3 (MinIO) | `YAML_API_TEST_S3_ENDPOINT` | + +Some tests are skipped instead of failing when a tool is missing: + +- without Docker and without the variable above, the tests that need that service; +- without `node` or `npm` on the `PATH`, the tests that run generated frontend code; +- without `YAML_API_TEST_DRF_PYTHON`, the tests that serve a generated Django REST Framework + backend. Set it to a Python interpreter that has the generated DRF project's requirements + installed. -## Where the code lives +CI runs only the unit suite. If your change touches generated backends, datastores or +authentication, run the integration suite locally as well. -| Path | Responsibility | -| --- | --- | -| `src/yaml_api_generator/parsers/` | JSON Schema validation, desugaring, and version transformers that read a spec into the IR | -| `src/yaml_api_generator/ir/` | the intermediate representation every generator consumes | -| `src/yaml_api_generator/generators/` | backend, frontend and deploy emitters | -| `src/yaml_api_generator/server/` | the development server that runs a spec without compiling it | -| `src/yaml_api_generator/cli/` | the `stackform` command | -| `examples/` | the specification corpus; `valid/` and `invalid/` are consumed by the tests | -| `docs/` | the manual, published to the documentation site on merge | +To run one file or one test, pass it to pytest as usual: + +```bash +uv run pytest tests/unit/test_parser.py -k +``` + +## Where things live + +| Path | Contents | +| ---------------------------------------- | ---------------------------------------------------------- | +| `src/yaml_api_generator/cli/` | The `stackform` command | +| `src/yaml_api_generator/parsers/` | Spec loading and validation; JSON Schemas in `schemas/` | +| `src/yaml_api_generator/ir/` | The intermediate representation the generators consume | +| `src/yaml_api_generator/generators/` | `backend/` (`fastapi`, `drf`, `raw`), `frontend/`, `deploy/` | +| `src/yaml_api_generator/server/` | The development server behind `stackform run` | +| `src/yaml_api_generator/runtime/` | Modules shared by the development server and generated applications | +| `tests/unit/`, `tests/integration/` | The two test suites | +| `tests/fixtures/valid/`, `tests/fixtures/invalid/` | Specs that must load and specs that must be rejected | +| `examples/` | Example specs, grouped by spec version | +| `docs/` | The manual, one directory per spec version | + +The Python package keeps the name `yaml_api_generator`; the distribution and the command are +named `stackform`. ## Changing the specification language -The language is versioned, and each version has its own schema under -`src/yaml_api_generator/parsers/schemas/`. A change to the language is only complete when all of -the following land together: +A spec file selects its grammar with its `version:` key. Each version has a JSON Schema in +`src/yaml_api_generator/parsers/schemas/` (for example `yaml-spec-v0.3.schema.json`). A pull +request that adds or changes a spec key carries: + +- the schema change and the parser change for the affected version; +- a spec in `tests/fixtures/valid/` that uses the key, and specs in `tests/fixtures/invalid/` for + each way it can be misused, with unit tests that load them; +- the generator change for every backend target that supports the feature; +- the manual page for the key in `docs//`, and an entry in that version's + `grammar-reference.md`. -1. the schema for the version that introduces it, -2. a transformer under `parsers/transformers/` if an older version must keep parsing, -3. an example under `examples/valid/` (and, where the change adds a rejection, one under - `examples/invalid/`), -4. unit tests covering both the accepted and the rejected shapes, -5. the manual page under `docs/` for that version. +## Documentation -A specification feature with no example is treated as undocumented and will be asked for in review. +The manual is written in Markdown under `docs/`, with one directory per spec version. Each page +starts with front matter that sets `title` and `description`, and each directory's `meta.json` +sets the page order. When you add a page, add it to `meta.json`. + +The site is built with Fumadocs from the separate +[yaml-framework-docs](https://github.com/sebastiaoteixeira/yaml-framework-docs) repository. A push +to `main` that changes `docs/` triggers a rebuild. To preview your changes locally, clone that +repository next to this one and link the content in: + +```bash +git clone https://github.com/sebastiaoteixeira/yaml-framework-docs.git +cd yaml-framework-docs +mkdir -p content +ln -s "$(pwd)/../stackform/docs" content/docs +npm install +npm run dev +``` + +The site is then served at . ## Pull requests -- Branch from `main`, named `/` — for example `fix/login-route-404` or - `feat/field-level-scoping`. -- One concern per pull request. A refactor bundled with a behaviour change is split before review. -- The title states the change in the imperative, and the body says what a reviewer should check. -- Keep generated output, local scratch files and client material out of the commit; `git status` - should be clean of them before you stage. +1. Create a branch named `/`, where the type is `feat`, `fix`, `docs`, + `refactor`, `ci` or `chore`. If the branch implements an issue, put its number after the type, + for example `feat/83-pagination`. +2. Keep each pull request to one change. Unrelated fixes go in separate pull requests. +3. Add or update tests for the behaviour you change, and update the manual if users will notice + the change. +4. Run the unit suite, and the integration suite where it applies. +5. Open the pull request against `main`. In the description, say what changes and how you tested + it, and link the issue it resolves (for example `Closes #83`). + +Pull requests are squash-merged, so the pull request title becomes the commit message on `main`. +Write the title as a single sentence in the imperative mood that states the change, without a type +prefix or a trailing period. For example: + +- `Reject deploy build contexts that enclose the spec or output directory` +- `Await pymongo async aggregate and list_indexes cursors` +- `Add file datastores and file field type` -## Reporting issues +No formatter or linter is enforced; follow the style of the code around your change. -Open an issue with the specification that reproduces the problem, the command you ran, and the -observed against the expected output. A minimal spec that still fails is worth more than a large -one that fails for several reasons at once. +CI runs the unit suite on Python 3.11 and 3.13 for every pull request. A maintainer reviews the pull +request once CI passes and may ask for changes before merging. ## Licence -Stackform is released under the MIT licence. By contributing you agree that your contribution is -licensed under the same terms. +Stackform is released under the [MIT License](LICENSE.txt). By submitting a contribution, you agree +that it is licensed under the same terms. diff --git a/docs/index.md b/docs/index.md index 92719955..d5997b9a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -103,5 +103,5 @@ YAML Spec --> Parser --> Intermediate Representation --> Code Generator --> Fast ### Spec Documents -- [YAML Specification v0.1](./YAML-Specification-v0.1.md) -- [YAML Structure Spec v0.1](./YAML-Structure-Spec-v0.1.md) +- [v0.1 Specification](./v0.1/specification) +- [v0.1 Grammar Reference](./v0.1/grammar-reference) diff --git a/docs/v0.1/grammar-reference.md b/docs/v0.1/grammar-reference.md new file mode 100644 index 00000000..06b08bec --- /dev/null +++ b/docs/v0.1/grammar-reference.md @@ -0,0 +1,156 @@ +--- +title: Grammar Reference +description: Every key a v0.1 spec accepts, with its type, in one tree. +--- + +This tree lists every key a v0.1 spec accepts. Every object is closed: a key +that does not appear here fails the build with `SCHEMA_VALIDATION_ERROR` +(`'' was unexpected`). The [specification](./specification.md) describes +what each key does. + +Notation: + +- `!` = required +- `?` = optional +- `map` = mapping from names to values +- `enum(...)` = one of the listed values +- `array` = list of values +- `oneOf(...)` = one of the listed forms + +```yaml +YAML-SPEC: + version!: "0.1" + + api?: + basePath?: string # default: /api + version?: string + pagination?: + defaultPageSize?: integer >= 1 # default: the smaller of 20 and maxPageSize + maxPageSize?: integer >= 1 # default: 100 + operations?: + defaults?: OperationFlags + + mongo?: + uri?: string # default: mongodb://localhost:27017 + database?: string # default: yaml_api + + security?: + cors?: + allowedOrigins?: array # no "*" when allowCredentials is true + allowCredentials?: boolean # default: false + allowedMethods?: array # default: ["*"] + headers?: + enableStandard?: boolean # default: true + + providers?: map + + collections!: map + + ui?: UiConfig # see the UI reference + + +OperationFlags: + list?: boolean + get?: boolean + create?: boolean + update?: boolean + replace?: boolean + delete?: boolean + + +ProviderConfig: oneOf(OIDCProvider, SAMLProvider) + +OIDCProvider: + type!: oidc + client_id!: string + client_secret!: string + +SAMLProvider: + type!: saml + idp_metadata_url!: string + sp_entity_id!: string + + +CollectionConfig: + type?: enum(base, auth) # default: base + fields!: map # exactly one field of type id + auth?: AuthConfig # required when type is auth + roles?: RolesConfig # only when type is auth + operations?: OperationFlags + access?: + list?: Access + get?: Access + create?: Access + update?: Access + replace?: Access # default: the update access + delete?: Access + softDelete?: boolean # default: false + + +AuthConfig: + identifier!: string # a field of the collection + via!: oneOf(string, array) # password, apiKey or a providers key + + +RolesConfig: + values!: array # at least one role + default!: string # one of values + hierarchy?: map> # role -> roles it includes + + +FieldConfig: # a top-level collection field + + unique?: boolean + +NestedFieldConfig: # an object property + + +ItemConfig: # the items of an array + type!: + + +Keys of every type, except in ItemConfig: + type!: enum(string, number, boolean, datetime, enum, array, object, ref, id) + required?: boolean + default?: any # must satisfy the field's own keys + +Keys added by type: + string: + minLength?: integer >= 0 + maxLength?: integer >= 0 + format?: enum(email, e164, uri, uuidv7, uuidv4) + number: + min?: number + max?: number + enum: + values!: array # at least one value + array: + items!: ItemConfig + object: + properties?: map + additionalProperties?: boolean # default: false with properties, true without + ref: + ref!: string # a declared collection + id: + format?: enum(uuidv7, uuidv4) # default: uuidv7 + boolean, datetime: no further keys + + +Access: oneOf(Principal, array) + +Principal: oneOf( + public, + "*", + :*, + :, + # only with a single auth collection +) + +AccessRule: + principal!: Principal + when?: string # when expression + reason?: string # 403 detail when the rule refuses +``` + +The `ui` block has the same shape in every spec version; see the +[UI reference](../v0.2/Reference/ui/overview.md). diff --git a/docs/v0.1/index.md b/docs/v0.1/index.md index a7eecbcc..f8b488da 100644 --- a/docs/v0.1/index.md +++ b/docs/v0.1/index.md @@ -3,13 +3,11 @@ title: v0.1 Documentation description: Stackform v0.1 specification reference. --- -v0.1 documentation: +A v0.1 spec declares MongoDB collections and gets a CRUD API for each of them, +with authentication, roles and access rules. `stackform run` serves it with the +development server, and `stackform build` generates a FastAPI, Django REST +Framework or raw-ASGI backend with the same behaviour. -- [Specification](/docs/v0.1/specification) — YAML spec reference for v0.1 -- [Structure](/docs/v0.1/structure) — project structure - -The v0.1 dialect remains executable, not validation-only. `stackform run` -serves its routes under `api.basePath` and optional `api.version`, and -`stackform build` supports the FastAPI, Django REST Framework, and raw-ASGI -targets. The v0.1 specifications in `examples/valid/` are exercised against -all three generators in the compatibility test suite. +- [Specification](./specification.md): what each key does, the requests it + serves and the errors it reports +- [Grammar reference](./grammar-reference.md): every key a v0.1 spec accepts diff --git a/docs/v0.1/meta.json b/docs/v0.1/meta.json index 3ceac756..5be518f0 100644 --- a/docs/v0.1/meta.json +++ b/docs/v0.1/meta.json @@ -3,6 +3,6 @@ "root": true, "pages": [ "specification", - "structure" + "grammar-reference" ] } diff --git a/docs/v0.1/specification.md b/docs/v0.1/specification.md index c8278ab9..e426cb56 100644 --- a/docs/v0.1/specification.md +++ b/docs/v0.1/specification.md @@ -1,427 +1,449 @@ --- title: Specification -description: "The v0.1 YAML contract: design goals, the transport-agnostic core model, and the compiled step pipelines it lowers to." +description: "What each v0.1 key does: collections, fields, payload validation, authentication, roles, access rules, soft delete, pagination, security and build errors." --- -Status: Draft v0.1 -Target: YAML-driven API engine with a transport-agnostic core and compiled step pipelines. +A v0.1 spec declares MongoDB collections and gets a CRUD API for each of them. +`stackform run spec.yaml` serves the spec with the development server, and +`stackform build spec.yaml -t fastapi|drf|raw` generates a FastAPI, Django REST +Framework or raw-ASGI backend with the same behaviour. The +[grammar reference](./grammar-reference.md) lists every accepted key. -## 1. Design Goals and Scope (v0.1) +## 1. Top-Level Sections -This specification defines a stable, implementation-oriented YAML contract for generating CRUD APIs over MongoDB. +| Section | Required | Purpose | +|---|---:|---| +| `version` | yes | Always `"0.1"`. | +| `api` | no | Route prefix, pagination and the operations every collection serves. | +| `mongo` | no | The MongoDB connection. | +| `security` | no | CORS and standard security headers. | +| `providers` | no | Named OIDC or SAML providers that `auth.via` may list. | +| `collections` | yes | The data model: one entry per MongoDB collection. | +| `ui` | no | A generated frontend; see the [UI reference](../v0.2/Reference/ui/overview.md). | -### 1.1 Goals +Every object in the spec is closed. A misspelled or unknown key fails the build +with `SCHEMA_VALIDATION_ERROR`, naming the key and its path. -- The spec **MUST** be deterministic: the same YAML input produces equivalent behavior in interpreted and compiled modes. -- The spec **MUST** be compile-friendly: static declarations are validated and lowered into executable pipelines at startup/build time. -- The core model **MUST** remain transport-agnostic: route/operation intents are protocol-neutral, then adapted to REST in v0.1. -- The spec **SHOULD** optimize for safe defaults (private by default, explicit public access, explicit write permissions). -- The spec **SHOULD** keep YAML expressive enough for common use cases without becoming a general-purpose language. +## 2. Interpolation -### 1.2 In Scope for v0.1 +Any string value may read the environment when the spec is loaded: -- Collection and field modeling with core constraints. -- Auth collections with collection-local auth configuration. -- Generated CRUD operations with per-operation toggles. -- Access-rule declarations compilable to step pipelines. -- Security and CORS baseline configuration. -- Startup/build-time validation and actionable error reporting. +- `${VAR}` takes the value of `VAR`; the build fails with + `INTERPOLATION_MISSING_VAR` when it is unset. +- `${VAR:-default}` takes `default` when `VAR` is unset. -### 1.3 Out of Scope for v0.1 +Inside a YAML flow mapping (`{ ... }`), quote the value: `database: "${MONGO_DB}"`. +See [environment variables and interpolation](../v0.2/Reference/configuration/environment-variables.md). -- Full provider protocol config (OIDC/OAuth/SAML runtime details). -- Custom function runtimes and transactional function semantics. -- Realtime subscriptions, webhooks, and advanced lifecycle logic DSL. +## 3. Collections -## 2. Interpolation Semantics +`collections` maps each collection name to its configuration. The name is the +MongoDB collection and the route segment. -Interpolation is supported in scalar string values. - -### 2.1 Supported Forms - -- `${VAR}`: required environment variable. -- `${VAR:-default}`: optional environment variable with fallback default. - -### 2.2 Resolution Rules - -- Resolution **MUST** happen before schema validation that depends on interpolated values. -- `${VAR}` **MUST** fail validation if `VAR` is unset. -- `${VAR:-default}` **MUST** resolve to `default` when `VAR` is unset. -- Empty-string values **MUST** be treated as set values unless an implementation profile states otherwise. -- Interpolation **MUST NOT** be recursively evaluated inside resolved values in v0.1. - -### 2.3 Future Provider References (Reserved) - -v0.1 reserves provider-based references for future versions. The following syntax is reserved and **MUST** be rejected in v0.1 unless a feature flag enables it: - -- `${{ env.VAR }}` -- `${{ secret.vault.path#key }}` -- `${{ secret.aws-sm.name#jsonKey }}` - -## 3. Top-Level Schema - -Top-level document type: object. - -### 3.1 Sections +| Key | Default | Meaning | +|---|---|---| +| `type` | `base` | `auth` makes the collection an identity collection (section 6). | +| `fields` | required | The record fields (section 4). | +| `auth` | - | Required when `type: auth`. | +| `roles` | - | Roles of an auth collection (section 7). | +| `operations` | `api.operations.defaults` | Which CRUD operations the collection serves (section 8). | +| `access` | public | Who may call each operation (section 9). | +| `softDelete` | `false` | Delete stamps `deletedAt` instead of erasing (section 10). | -| Section | Type | Required | Description | -|---|---|---:|---| -| `version` | string | YES | Specification/API config version string (e.g. `"0.1"`, `"v1"`). | -| `api` | object | NO | API generation defaults and operation toggles (defaults apply when omitted). | -| `collections` | map | YES | Declared data models. | -| `security` | object | NO | CORS, headers, and secret-redaction controls. | -| `access` | object | NO | Reusable access rule fragments/options. | -| `options` | object | NO | Engine-level options (strictness/warnings policy). | +Each collection declares exactly one field of `type: id` (`ID_FIELD_MISSING`, +`ID_FIELD_MULTIPLE`). The server generates its value on create (UUIDv7, or UUIDv4 +with `format: uuidv4`); a request body that sends it is rejected with 422. -### 3.2 Unknown Keys +## 4. Fields and Payload Validation -- Unknown top-level keys **SHOULD** trigger warnings by default. -- Strict mode **MAY** elevate unknown keys to errors. +Every field accepts `type`, `required` and `default`; top-level fields also +accept `unique`. The other keys depend on the type: -## 4. Collection Schema +| Type | Extra keys | Accepted JSON value | +|---|---|---| +| `string` | `minLength`, `maxLength`, `format` (`email`, `e164`, `uri`, `uuidv7`, `uuidv4`) | a string | +| `number` | `min`, `max` | a number; a numeric string is rejected | +| `boolean` | - | `true` or `false` | +| `datetime` | - | an ISO 8601 date or date-time string | +| `enum` | `values` (required) | one of `values` | +| `array` | `items` (required): a nested field | a list whose items satisfy `items` | +| `object` | `properties`, `additionalProperties` | an object | +| `ref` | `ref` (required): a collection name | the id of a record in that collection | +| `id` | `format` (`uuidv7`, `uuidv4`) | generated by the server | + +A key that does not belong to the field's type fails the build with +`SCHEMA_VALIDATION_ERROR`. The build also checks that: + +- `min` is not greater than `max`, and `minLength` not greater than `maxLength` + (`FIELD_CONSTRAINT_INVALID`); +- a `default` satisfies the field's own keys, nested properties and items + included (`FIELD_CONSTRAINT_INVALID`); +- `ref` names a declared collection (`REF_TARGET_NOT_FOUND`); +- collection and field names are identifiers: a letter followed by letters, + digits or underscores, neither a Python keyword nor a name the generated + models reserve (`INVALID_NAME`, `INVALID_FIELD_NAME`). + +Request bodies are validated alike by the development server and every +generated backend, and an invalid body answers 422: + +- A field the collection does not declare is rejected (`Unknown fields: `). +- `required` fields must be present on create and replace; update (`PATCH`) + accepts any subset of fields, the empty body included. +- A field left out of a create keeps its `default` when it declares one and is + otherwise absent from the stored record. +- An `object` field with `properties` is closed: a key it does not list is + rejected unless `additionalProperties: true`. An `object` field without + `properties` accepts any keys. +- `datetime` values are stored and returned as UTC timestamps: `"2024-01-01"` + becomes `"2024-01-01T00:00:00+00:00"`. +- `unique: true` creates a unique index; a create or update that repeats a + stored value answers 409. + +## 5. API Section + +| Key | Default | Meaning | +|---|---|---| +| `api.basePath` | `/api` | Prefix of every route. | +| `api.version` | none | Second prefix segment: `/api/v1/...` with `version: v1`. | +| `api.pagination` | see section 11 | List page sizes. | +| `api.operations.defaults` | every operation on | Operations every collection serves unless it overrides them. | -`collections` is a map keyed by collection name. +## 6. Auth Collections -### 4.1 CollectionConfig +A collection with `type: auth` holds identities that callers authenticate as. -| Field | Type | Required | Default | Notes | -|---|---|---:|---|---| -| `type` | enum | NO | `base` | Collection category. v0.1 values: `base`, `auth`. | -| `fields` | map | YES | - | Field definitions. | -| `operations` | OperationsConfig | NO | inherit `api.operations.defaults` | Per-collection operation toggles. | -| `softDelete` | boolean | NO | `false` | Soft-delete behavior. | -| `indexes` | array | NO | `[]` | Optional index declarations. | +```yaml +collections: + users: + type: auth + auth: + identifier: email + via: [password, apiKey] + fields: + id: { type: id } + email: { type: string, required: true, unique: true } + apiKey: { type: string, required: true } +``` -### 4.2 FieldConfig +`auth.identifier` names the field that identifies the caller +(`AUTH_IDENTIFIER_FIELD_NOT_FOUND` when the collection does not declare it); mark +it `unique: true`. `auth.via` lists how callers authenticate: `password`, +`apiKey`, or the name of an entry under `providers` +(`AUTH_VIA_UNKNOWN_PROVIDER` otherwise). -| Field | Type | Required | Default | Constraints | -|---|---|---:|---|---| -| `type` | enum | YES | - | `string`, `number`, `boolean`, `datetime`, `enum`, `array`, `object`, `ref`, `id` | -| `required` | boolean | NO | `false` | Enforced on create; on update, only when field provided unless full replace. | -| `default` | any | NO | none | Must be type-compatible. | -| `unique` | boolean | NO | `false` | Implies unique index recommendation. | -| `min` | number | NO | none | Number lower bound. | -| `max` | number | NO | none | Number upper bound. | -| `minLength` | integer | NO | none | String lower bound. | -| `maxLength` | integer | NO | none | String upper bound. | -| `format` | enum | NO | none | `email`, `e164`, `uri`, `uuidv7`, `uuidv4` (v0.1 set). | -| `values` | array | conditional | none | Required when `type: enum`. | -| `items` | FieldConfigLite | conditional | none | Required when `type: array`. | -| `properties` | map | NO | none | For typed object fields. | -| `additionalProperties` | boolean | NO | `false` | If true, object is schemaless-compatible. | -| `ref` | string | conditional | none | Required when `type: ref`; target collection name. | -| `store` | enum | NO | `id` | For refs: `id` or `embed`. | -| `expand` | object | NO | none | Expansion policy (`allowed`, `always`, `fields`, `maxDepth`). | +### Password -### 4.3 Field Validation Rules +- The create body carries a `password` field the spec does not declare; it is + required (422 without it). Declaring `password` or `passwordHash` in `fields` + fails the build (`AUTH_PASSWORD_FIELD_FORBIDDEN`, + `AUTH_PASSWORD_HASH_FIELD_FORBIDDEN`). +- `POST {prefix}/{collection}/login` with the identifier and `password`, for + example `{"email": "ann@example.com", "password": "..."}`, returns + `{"token": "", "user": {...}}`. Wrong credentials answer 401 + `{"detail": "Invalid credentials"}`. +- Requests authenticate with `Authorization: Bearer ` or with HTTP Basic + credentials (identifier and password). -- `min`/`max` **MUST** only apply to `number`. -- `minLength`/`maxLength` **MUST** only apply to `string`. -- `format` for `string` **MUST** be one of `email`, `e164`, `uri`. -- `format` for `id` **MAY** be `uuidv7` or `uuidv4`; default is `uuidv7`. -- Each collection **MUST** define exactly one top-level `type: id` field. -- `values` **MUST** be present and non-empty for `enum`. -- `items` **MUST** be present for `array`. -- `ref` **MUST** target an existing collection. -- `unique: true` fields **SHOULD** be reconciled with backend indexes at startup. +### API key -## 5. Identity and Authentication Schema +- The collection declares an `apiKey` field (`AUTH_API_KEY_FIELD_MISSING` + otherwise). The key sent on create is stored hashed. +- Requests authenticate with the `X-API-Key` header or `Authorization: Bearer `. -Collections are identity-capable when `collections..type` is set to `auth`. -Authentication settings are declared directly inside each auth collection at `collections..auth`. +Passwords, password hashes and API keys are never returned by any route. -### 5.1 Collection Auth Config +### Providers -`collections..auth` schema (required when `collections..type: auth`): +`providers.` declares an OIDC provider (`client_id`, `client_secret`) or a +SAML provider (`idp_metadata_url`, `sp_entity_id`); `auth.via` may list its name +next to `password` and `apiKey`. Each provider type accepts only its own keys. -| Field | Type | Required | Default | -|---|---|---:|---| -| `identifier` | string | YES | - | -| `via` | `password` OR `apiKey` OR array of those values | YES | - | +```yaml +providers: + corporate: + type: saml + idp_metadata_url: ${SAML_IDP_METADATA_URL} + sp_entity_id: https://sp.example.com/metadata +collections: + users: + type: auth + auth: { identifier: email, via: [password, corporate] } +``` -`via` accepts either a single method or multiple methods, for example: +A SAML provider listed in `via` signs users in through the +[SAML routes](../v0.2/Reference/providers/saml.md#generated-routes) under the +collection path (`/api/users/auth/corporate`, its `/callback`, `/metadata` and +`/logout`). The assertion's NameID fills the collection's `identifier`, and a +user signing in for the first time gets a new record. The login code the callback returns is redeemed at `POST /api/auth/exchange` +for the app token, and the generated UI offers sign-in through the provider. +`stackform run` and the `fastapi` target serve these routes; the `drf` and `raw` +targets and `stackform run --raw` reject the provider with +`BACKEND_FEATURE_UNSUPPORTED`. -- `via: password` -- `via: [password, apiKey]` +## 7. Roles -Rules: +An auth collection may declare roles: -- `identifier` **MUST** reference a field in the auth collection. -- `identifier` **SHOULD** be marked `unique: true`. -- `via` values **MUST** be canonical and case-sensitive: `password` and `apiKey`. -- API key authentication **MUST** use the standardized method name `apiKey`. -- Any other `via` value (including aliases such as `apikey`, `api_key`, or `api-key`) **MUST** fail validation. -- When `via` includes `password`, request body input `password` is implicit for create operations. -- When `via` includes `password`, storage field `passwordHash` is implicit and **MUST NOT** be declared in YAML fields. -- Credential material (password hash, reset tokens, API keys) **MUST NOT** be returned by generated endpoints. +```yaml + roles: + values: [reader, editor, admin] + default: reader + hierarchy: + admin: [editor] + editor: [reader] +``` -## 6. API Operation Generation Schema +- Every new identity receives the `default` role. A request body cannot set + roles; they are stored with the identity's credentials, under `_auth.roles` + in its MongoDB document. +- `hierarchy` maps a role to the roles it includes, transitively: with the + hierarchy above an `admin` passes an `editor` or `reader` rule. +- `default` and every role named in `hierarchy` must be one of `values` + (`ROLES_DEFAULT_UNKNOWN_ROLE`, `ROLES_HIERARCHY_UNKNOWN_ROLE`), and the + hierarchy cannot be circular (`ROLES_HIERARCHY_CYCLE`). +- `roles` is accepted only on a `type: auth` collection. -### 6.1 Global API Section +## 8. Operations and Routes -`api` is optional in v0.1. When omitted, engines MUST use defaults. +Each enabled operation serves one route under `{basePath}[/{version}]`: -`api` schema: +| Operation | Route | Success | +|---|---|---| +| `list` | `GET /{collection}` | 200, a JSON array | +| `get` | `GET /{collection}/{id}` | 200 | +| `create` | `POST /{collection}` | 200, the stored record | +| `update` | `PATCH /{collection}/{id}` | 200, the updated record | +| `replace` | `PUT /{collection}/{id}` | 200, the replaced record | +| `delete` | `DELETE /{collection}/{id}` | 200 | -| Field | Type | Required | Default | -|---|---|---:|---| -| `basePath` | string | NO | `/api` | -| `version` | string | NO | none | -| `pagination` | object | NO | `{ defaultPageSize: 20, maxPageSize: 100 }` | -| `operations` | object | NO | `{ defaults: { list: true, get: true, create: true, update: true, replace: true, delete: true } }` | +An auth collection whose `via` includes `password` also serves +`POST /{collection}/login`. -`api.operations.defaults` schema: +Every operation is on unless `api.operations.defaults` turns it off; +`collections..operations` overrides the defaults for one collection. A +disabled operation has no route. -| Operation | Type | Default | -|---|---|---| -| `list` | boolean | `true` | -| `get` | boolean | `true` | -| `create` | boolean | `true` | -| `update` | boolean | `true` | -| `replace` | boolean | `true` | -| `delete` | boolean | `true` | +## 9. Access Rules -### 6.2 Per-Collection Overrides +`collections..access.` decides who may call the operation. An +operation without an access entry is public. `replace` without an entry of its +own uses the `update` entry. -- `collections..operations` **MAY** override any global default. -- Effective operation enablement **MUST** be computed at compile/startup time. -- Disabled operations **MUST NOT** register routes/handlers in generated output. +An entry is a principal or a list of rules: -## 7. Security, CORS, and Secret Redaction +| Principal | Who passes | +|---|---| +| `public` | Anyone, authenticated or not. | +| `"*"` | Any authenticated caller. Quote it: a bare `*` is a YAML alias. | +| `:*` | Any identity of that auth collection. | +| `:` | An identity of that collection holding the role. | +| `` | The role on the only auth collection (`ACCESS_AMBIGUOUS_ROLE` with several). | -`security` schema: +A principal naming an undeclared auth collection fails with +`ACCESS_UNKNOWN_ENTITY`; a bare role in a spec without auth collections fails +with `ACCESS_NO_AUTH_ENTITY`. -| Field | Type | Required | Default | -|---|---|---:|---| -| `cors.allowedOrigins` | array | NO | `[]` | -| `cors.allowCredentials` | boolean | NO | `false` | -| `cors.allowedMethods` | array | NO | implementation default | -| `headers.enableStandard` | boolean | NO | `true` | -| `redaction.enabled` | boolean | NO | `true` | -| `redaction.keys` | array | NO | see below | +A rule adds a condition and a refusal message: -Rules: +```yaml + access: + get: + - principal: "users:*" + when: principal.sub equals document.ownerId + reason: Only the owner can read this post. +``` -- If `allowCredentials: true`, wildcard origin (`*`) **MUST NOT** be accepted. -- Standard security headers **SHOULD** be emitted when `headers.enableStandard` is true. -- Redaction **MUST** apply to logs, errors, diagnostics, and debug dumps. -- Default redacted key set **SHOULD** include case-insensitive matches for: `password`, `secret`, `token`, `apiKey`, `authorization`, `cookie`. -- Interpolated secret values **MUST NOT** be emitted in plaintext in any engine logs. +- `when` is a [when expression](../v0.2/Reference/when-expressions/overview.md) + over `principal` (`principal.sub`, `principal.roles`, `principal.record.`) + and `document`, the record the route addresses. A `create` rule has no record, + so naming `document` there fails with `ACCESS_RULE_INVALID`; a malformed + expression fails with `WHEN_EXPR_INVALID`. +- Rules are tried in order and the first that admits the caller decides. A + `public` rule makes the operation public. +- On `list`, a `when` over `document` filters rows: the caller receives only the + records the rules admit. +- A caller without valid credentials gets 401. An authenticated caller no rule + admits gets 403 with the first `reason` among the rules as `detail`, or + `Forbidden`. A missing record answers 404 before any rule is checked. -## 8. Access Rule Schema (Pipeline-Compatible) +## 10. Soft Delete -Access rules are declarative and compile into ordered evaluation steps. +With `softDelete: true`, `DELETE` stamps a `deletedAt` timestamp on the record +instead of erasing it. A removed record is absent from `list` and answers 404 +on `get`, `update`, `replace` and `delete`. -### 8.1 Collection Access Shape +`deletedAt` is added to the collection as a read-only `datetime`; a request body +that sends it is rejected. A collection may declare `deletedAt` itself only as a +`datetime` field (`LIFECYCLE_FIELD_NOT_DATETIME`). -`collections..access`: +## 11. Pagination -| Field | Type | Required | Description | -|---|---|---:|---| -| `list` | `AccessOperationConfig` | NO | Access policy for list operation. | -| `get` | `AccessOperationConfig` | NO | Access policy for get-by-id. | -| `create` | `AccessOperationConfig` | NO | Access policy for create. | -| `update` | `AccessOperationConfig` | NO | Access policy for partial update. | -| `replace` | `AccessOperationConfig` | NO | Access policy for full replace. | -| `delete` | `AccessOperationConfig` | NO | Access policy for delete. | +`list` reads `limit` and `offset` query parameters: -Shorthand values are also valid for each operation key: +- `limit` defaults to `api.pagination.defaultPageSize` and is clamped to + `api.pagination.maxPageSize`. A `limit` that is zero, negative, blank or not a + number answers 422. +- `maxPageSize` defaults to 100 and `defaultPageSize` to the smaller of 20 and + `maxPageSize`. A `defaultPageSize` larger than `maxPageSize` fails the build + with `PAGINATION_INVALID_SIZE`. -- `public`: operation is publicly accessible. -- `"*"`: operation requires a resolved identity. -- `:*`: operation requires identity from a specific auth collection. -- Rule array (`array`): advanced allow rules. +## 12. MongoDB Connection -### 8.2 AccessRule +```yaml +mongo: + uri: ${MONGO_URI:-mongodb://localhost:27017} + database: ${MONGO_DB:-blog} +``` -| Field | Type | Required | Default | Notes | -|---|---|---:|---|---| -| `principal` | string | YES | - | Who the rule applies to. | -| `when` | string (expression) | NO | `true` | Condition over context/document/input. | -| `filter` | string (expression) | NO | none | Row-level constraint for read/list/update/delete. | -| `writeFields` | array | NO | `[]` | Allowed writable fields. | -| `reason` | string | NO | none | Optional diagnostics label. | +`uri` defaults to `mongodb://localhost:27017` and `database` to `yaml_api`. See +[`mongo:`](../v0.2/Reference/configuration/mongo.md) for the variables the +development server and the generated apps read at run time. -`principal` supports: +## 13. Security -- `public` -- `"*"` -- `users:*` +### CORS -Compilation semantics: +Without `security.cors` no CORS headers are sent. With it: -- Rules **MUST** be evaluated in declaration order. -- Engine **MUST** support first-match mode in v0.1 (configurable later). -- Expressions **MUST** compile to an internal AST/pipeline step at startup. -- Expression context **MUST** support `currentIdentity`, `document`, `input`, and provider passthrough claims when present. +| Key | Default | Meaning | +|---|---|---| +| `allowedOrigins` | `[]` | Origins allowed to call the API. | +| `allowCredentials` | `false` | Allow cookies and credentials. `"*"` in `allowedOrigins` is then rejected by the schema. | +| `allowedMethods` | `["*"]` | Methods allowed in preflight requests. | -## 9. Validation and Error Semantics +Preflight requests receive the headers they ask for in +`Access-Control-Request-Headers`. -### 9.1 Validation Phases +### Standard headers -Implementations **MUST** validate in this order: +`security.headers.enableStandard` (default `true`) adds these headers to every +response that does not set them itself: -1. YAML parse and type-shape checks. -2. Interpolation resolution and missing-variable checks. -3. Schema validation (required fields, type compatibility, enum constraints). -4. Cross-reference validation (collections, fields, auth refs, access principals). -5. Compilation validation (expression parse/compile, operation graph consistency). +| Header | Value | +|---|---| +| `X-Content-Type-Options` | `nosniff` | +| `X-Frame-Options` | `DENY` | +| `Referrer-Policy` | `no-referrer` | +| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains` | +| `Cross-Origin-Opener-Policy` | `same-origin` | -### 9.2 Error Contract +Set it to `false` when a proxy in front of the API adds them. -- Startup/build validation failures **MUST** prevent serving traffic. -- Each error **MUST** include: `code`, `message`, and `path` (YAML pointer-like path). -- Errors **SHOULD** include `hint` when an actionable fix is obvious. -- Multiple independent errors **SHOULD** be aggregated in one report when safe. -- Warnings **MUST NOT** block startup unless strict mode is enabled. +## 14. Build Errors -Recommended error shape: +A spec that fails validation is not served or generated. Each error names its +code, the path of the offending key and, where one applies, a hint: -```yaml -code: SCHEMA_INVALID_TYPE -message: "Field type mismatch: expected number" -path: "collections.posts.fields.views.default" -hint: "Set default to a numeric literal" +```text +spec.yaml:6:collections.posts.fields.views: error: default 'many' is invalid: Field 'collections.posts.fields.views' must be a number [FIELD_CONSTRAINT_INVALID] + 6 | views: { type: number, default: many } + ^ +note: Make the default satisfy the field's own constraints or remove it ``` -## 10. Complete Example (v0.1) +| Code | Cause | +|---|---| +| `SCHEMA_VALIDATION_ERROR` | An unknown key, a missing required key, or a value of the wrong type or outside its enum. | +| `UNSUPPORTED_VERSION` | `version` is not a supported spec version. | +| `INTERPOLATION_MISSING_VAR` | `${VAR}` with `VAR` unset. | +| `INTERPOLATION_MALFORMED_TOKEN` | A `${...}` that is not `${VAR}` or `${VAR:-default}`. | +| `INVALID_NAME`, `INVALID_FIELD_NAME` | A collection or field name that is not an identifier. | +| `NAME_PASCAL_CASE_COLLISION` | Two names that generate the same class name. | +| `ID_FIELD_MISSING`, `ID_FIELD_MULTIPLE` | A collection without exactly one `type: id` field. | +| `FIELD_CONSTRAINT_INVALID` | Inverted bounds, or a `default` that breaks its own field. | +| `REF_TARGET_NOT_FOUND` | A `ref` to an undeclared collection. | +| `AUTH_IDENTIFIER_FIELD_NOT_FOUND` | `auth.identifier` is not a field of the collection. | +| `AUTH_VIA_UNKNOWN_PROVIDER` | A `via` entry that is not `password`, `apiKey` or a `providers` key. | +| `AUTH_API_KEY_FIELD_MISSING` | `via: apiKey` without an `apiKey` field. | +| `AUTH_PASSWORD_FIELD_FORBIDDEN`, `AUTH_PASSWORD_HASH_FIELD_FORBIDDEN` | `password` or `passwordHash` declared with `via: password`. | +| `ROLES_DEFAULT_UNKNOWN_ROLE`, `ROLES_HIERARCHY_UNKNOWN_ROLE`, `ROLES_HIERARCHY_CYCLE` | Roles that are not declared, or a circular hierarchy. | +| `ACCESS_UNKNOWN_ENTITY`, `ACCESS_AMBIGUOUS_ROLE`, `ACCESS_NO_AUTH_ENTITY` | A principal that names no auth collection unambiguously. | +| `ACCESS_RULE_INVALID` | A `create` rule whose `when` names `document`. | +| `WHEN_EXPR_INVALID` | A `when` expression that does not parse. | +| `LIFECYCLE_FIELD_NOT_DATETIME` | A declared `deletedAt` that is not a `datetime` with `softDelete: true`. | +| `PAGINATION_INVALID_SIZE` | `defaultPageSize` larger than `maxPageSize`. | + +## 15. Complete Example ```yaml version: "0.1" api: - basePath: "/api" - version: "v1" + basePath: /api + version: v1 pagination: defaultPageSize: 20 maxPageSize: 100 operations: defaults: - list: true - get: true - create: true - update: true - replace: true - delete: true + replace: false + +mongo: + uri: ${MONGO_URI:-mongodb://localhost:27017} + database: ${MONGO_DB:-blog} security: cors: - allowedOrigins: - - "https://app.example.com" - - "${CORS_ORIGIN:-http://localhost:5173}" + allowedOrigins: ["${CORS_ORIGIN:-http://localhost:5173}"] allowCredentials: true - allowedMethods: ["GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS"] headers: enableStandard: true - redaction: - enabled: true - keys: ["password", "token", "apiKey", "authorization", "cookie", "secret"] collections: users: type: auth auth: identifier: email - via: [password] - fields: - email: - type: string - required: true - unique: true - format: email - passwordHash: - type: string - required: true - displayName: - type: string - required: true - minLength: 2 - maxLength: 120 - role: - type: enum - values: ["user", "admin"] - default: "user" - createdAt: - type: datetime - metadata: - type: object - additionalProperties: true + via: password + roles: + values: [reader, editor, admin] + default: reader + hierarchy: + admin: [editor] + editor: [reader] operations: delete: false access: - list: - - effect: deny - principal: { type: public } + create: public + list: "users:admin" get: - - effect: allow - principal: { type: identity, collection: users } - when: "document._id == currentIdentity._id or currentIdentity.role == 'admin'" + - principal: "users:*" + when: principal.sub equals document.id + reason: Only the account owner can read it. update: - - effect: allow - principal: { type: identity, collection: users } - when: "document._id == currentIdentity._id" - writeFields: ["displayName", "metadata"] - create: - - effect: deny - principal: { type: public } + - principal: "users:*" + when: principal.sub equals document.id + fields: + id: { type: id } + email: { type: string, required: true, unique: true, format: email } + displayName: { type: string, required: true, minLength: 2, maxLength: 120 } + metadata: { type: object } posts: - type: base - fields: - title: - type: string - required: true - minLength: 3 - maxLength: 160 - body: - type: string - required: true - authorId: - type: ref - ref: users - required: true - store: id - expand: - allowed: true - fields: ["_id", "displayName"] - maxDepth: 1 - tags: - type: array - items: - type: string - published: - type: boolean - default: false - createdAt: - type: datetime softDelete: true access: - list: - - effect: allow - principal: { type: public } - filter: "document.published == true" - - effect: allow - principal: { type: identity, collection: users } - filter: "document.authorId == currentIdentity._id" - create: - - effect: allow - principal: { type: authenticated } + list: public + get: public + create: "users:editor" update: - - effect: allow - principal: { type: identity, collection: users } - when: "document.authorId == currentIdentity._id" - writeFields: ["title", "body", "tags", "published"] + - principal: "users:*" + when: principal.sub equals document.authorId delete: - - effect: allow - principal: { type: identity, collection: users } - when: "document.authorId == currentIdentity._id or currentIdentity.role == 'admin'" - -options: - strict: false + - principal: "users:admin" + - principal: "users:*" + when: principal.sub equals document.authorId + fields: + id: { type: id } + title: { type: string, required: true, minLength: 3, maxLength: 160 } + body: { type: string, required: true } + authorId: { type: ref, ref: users, required: true } + tags: { type: array, items: { type: string } } + published: { type: boolean, default: false } + publishedAt: { type: datetime } ``` -## 11. Conformance Notes - -- Engines claiming v0.1 conformance **MUST** implement required fields and rules in this document. -- Engines **MAY** add extension fields under namespaced keys (recommended prefix: `x-`). -- Extension fields **MUST NOT** alter required v0.1 semantics unless explicitly marked as non-conformant mode. +Anyone may sign up through `POST /api/v1/users` and becomes a `reader`. Readers +read and edit only their own account; an `editor` also writes posts and an +`admin` lists accounts and deletes any post. Authors edit and delete their own +posts, and a deleted post disappears from every route while its document keeps +a `deletedAt` stamp. diff --git a/docs/v0.1/structure.md b/docs/v0.1/structure.md deleted file mode 100644 index 69c55932..00000000 --- a/docs/v0.1/structure.md +++ /dev/null @@ -1,120 +0,0 @@ ---- -title: Structure -description: Notation and the full shape of a v0.1 spec document, key by key. ---- - -Notation: - -- `!` = required -- `?` = optional -- `map` = dictionary/object map -- `enum(...)` = allowed values -- `array` = list of values -- `oneOf(...)` = union type - -```yaml -YAML-SPEC: - version!: string - - api?: - basePath?: string - version?: string - pagination?: - defaultPageSize?: integer - maxPageSize?: integer - operations?: - defaults?: - list?: boolean - get?: boolean - create?: boolean - update?: boolean - replace?: boolean - delete?: boolean - - security?: - cors?: - allowedOrigins?: array - allowCredentials?: boolean - allowedMethods?: array - headers?: - enableStandard?: boolean - redaction?: - enabled?: boolean - keys?: array - - collections!: map - - options?: - strict?: boolean - - -CollectionConfig: - type?: enum(base, auth) # default: base - fields!: map - auth?: # required when type=auth - identifier!: string - via!: oneOf(enum(password, apiKey), array) - operations?: - list?: boolean - get?: boolean - create?: boolean - update?: boolean - replace?: boolean - delete?: boolean - access?: - list?: array - get?: array - create?: array - update?: array - replace?: array - delete?: array - softDelete?: boolean - indexes?: array - - -FieldConfig: - type!: enum(string, number, boolean, datetime, enum, array, object, ref, id) - required?: boolean - default?: any - unique?: boolean - min?: number - max?: number - minLength?: integer - maxLength?: integer - format?: enum(email, e164, uri) - values?: array # required when type=enum - items?: FieldConfigLite # required when type=array - properties?: map # for typed object - additionalProperties?: boolean - ref?: string # required when type=ref - store?: enum(id, embed) - expand?: - allowed?: boolean - always?: boolean - fields?: array - maxDepth?: integer - -AccessRule: - effect!: enum(allow, deny) - principal!: - type!: enum(public, authenticated, identity) - collection?: string # required when type=identity - when?: string - filter?: string - writeFields?: array - reason?: string - - -IndexConfig: - name?: string - fields!: array - unique?: boolean - sparse?: boolean - ttlSeconds?: integer - text?: boolean - - -IndexField: - field!: string - order?: enum(asc, desc) -``` diff --git a/docs/v0.2/LearnByExample/blog/01-overview.md b/docs/v0.2/LearnByExample/blog/01-overview.md index 0c80abe5..59db22c6 100644 --- a/docs/v0.2/LearnByExample/blog/01-overview.md +++ b/docs/v0.2/LearnByExample/blog/01-overview.md @@ -15,16 +15,13 @@ api: version: v1 ``` -> **Enforcement status** +> **Enforcement** > -> Roles (exact match), `:*` scoping, and ownership/ABAC `when` rules +> Roles, role hierarchies, `:*` scoping, and ownership/ABAC `when` rules > are enforced by the generated FastAPI backend, the generated DRF backend, and the -> `stackform run` dev server alike. A request that fails a rule gets 401 (missing or -> invalid credentials), 403 (authenticated but not allowed), or 404 (record not -> found, checked before ownership). Two current limitations: role hierarchy (for -> example `admin` inheriting `moderator`) is accepted in YAML but not yet applied -> during enforcement -- only exact role matches are checked; and conditional or -> ownership rules require a v0.2 spec, because v0.1 specs do not parse `when` rules. +> `stackform run` dev server alike. A request that no rule admits gets 401 when it +> carries no valid credentials and 403 otherwise; a missing record answers 404 +> before ownership is checked. ## Features @@ -37,11 +34,9 @@ api: - Natural-key country entity to demonstrate `pk:` - A custom `publish` operation backed by a user-provided handler module -The spec also declares an entity-level `roles: { values, default, hierarchy }` -block on `user`. That block is parsed but not wired in v0.2: it does not assign a -default role on create, and its hierarchy is not applied during enforcement. Roles -are taken from the user record and matched exactly. To attach roles to the auth -entity, use an enum field plus `auth.roles: ` (see the roles reference). +Roles live in the `user` entity's `role` enum field, named by `auth.roles`. A +`roles:` block on `user` adds a hierarchy: `admin` includes `moderator`, which +includes `user`. ## Entities diff --git a/docs/v0.2/LearnByExample/blog/02-spec.md b/docs/v0.2/LearnByExample/blog/02-spec.md index d1d43c4d..1890232e 100644 --- a/docs/v0.2/LearnByExample/blog/02-spec.md +++ b/docs/v0.2/LearnByExample/blog/02-spec.md @@ -11,16 +11,13 @@ The `api:` block below sets `basePath: /api` and `version: v1`, so every route is served under `/api/v1/...` (for example `POST /api/v1/users/login`). Change those two values and all URLs shift accordingly. -> **Enforcement status** +> **Enforcement** > -> Roles (exact match), `:*` scoping, and ownership/ABAC `when` rules +> Roles, role hierarchies, `:*` scoping, and ownership/ABAC `when` rules > are enforced by the generated FastAPI backend, the generated DRF backend, and the -> `stackform run` dev server alike. A request that fails a rule gets 401 (missing or -> invalid credentials), 403 (authenticated but not allowed), or 404 (record not -> found, checked before ownership). Two current limitations: role hierarchy (for -> example `admin` inheriting `moderator`) is accepted in YAML but not yet applied -> during enforcement -- only exact role matches are checked; and conditional or -> ownership rules require a v0.2 spec, because v0.1 specs do not parse `when` rules. +> `stackform run` dev server alike. A request that no rule admits gets 401 when it +> carries no valid credentials and 403 otherwise; a missing record answers 404 +> before ownership is checked. ```yaml version: "0.2" @@ -53,6 +50,12 @@ entities: via: password # Roles come from an enum field named here; the field must exist below. roles: role + roles: + values: [admin, moderator, user] + default: user + hierarchy: + admin: [moderator] + moderator: [user] fields: email: { type: string, required: true, unique: true } displayName: { type: string, required: true } @@ -81,13 +84,6 @@ entities: code: { type: string } name: { type: string, required: true } -# Optional: role hierarchy is parsed and reaches the React RBAC matrix, but it is -# NOT applied during access enforcement today (only exact role matches count). -roles: - user: - admin: [moderator] - moderator: [user] - repositories: users: entity: user @@ -174,13 +170,10 @@ controllers: `post` at build time. `country` uses `pk: code` instead, so it has a natural string key and no auto-generated id (the client supplies `code` on create). -- Roles. The working mechanism is `auth.roles: role` plus an enum field named - `role` (with `values` and a `default`). The role is read from that field at - request time; new users get `user` from the field's `default`. A standalone - entity-level `roles: { values, default, hierarchy }` block is silently ignored - by v0.2 -- do not use it. The top-level `roles:` map keyed by the auth entity - name (`user`) is parsed and feeds the React RBAC matrix, but hierarchy is not - yet applied during enforcement. +- Roles. `auth.roles: role` names an enum field `role` (with `values` and a + `default`). The role is read from that field at request time; new users get + `user`. The `roles:` block's `hierarchy` makes `admin` include `moderator` and + `moderator` include `user`, so an `admin` passes every `moderator` rule. - Repositories and CRUD. All six CRUD operations (list, get, create, update, replace, delete) are enabled by default, so none of these repositories needs a @@ -212,8 +205,8 @@ controllers: (`method` and `path` are required for a custom op). At runtime the handler is your own module: provide a `blog.py` next to the spec exposing `async def publish_post(request)`. The FastAPI, DRF, and raw-ASGI build targets - all emit the custom route; `stackform run` invokes the same declaration through - its `RequestContext` handler contract. + all emit the custom route, and they and `stackform run` call the handler with + the same request object. - Unique fields. `unique: true` on `email` drives the UI and creates a MongoDB unique partial index. Duplicate emails are rejected with `409 Conflict` without diff --git a/docs/v0.2/LearnByExample/blog/03-access-rules.md b/docs/v0.2/LearnByExample/blog/03-access-rules.md index 87537d64..36e68338 100644 --- a/docs/v0.2/LearnByExample/blog/03-access-rules.md +++ b/docs/v0.2/LearnByExample/blog/03-access-rules.md @@ -6,16 +6,13 @@ description: "How the blog spec's access rules map to roles, ownership, and the This page walks through the access rules in the blog spec ([Full Spec](./02-spec.md)) rule by rule, and shows the exact URLs each one guards. -> **Enforcement status** +> **Enforcement** > -> Roles (exact match), `:*` scoping, and ownership/ABAC `when` rules +> Roles, role hierarchies, `:*` scoping, and ownership/ABAC `when` rules > are enforced by the generated FastAPI backend, the generated DRF backend, and the -> `stackform run` dev server alike. A request that fails a rule gets 401 (missing or -> invalid credentials), 403 (authenticated but not allowed), or 404 (record not -> found, checked before ownership). Two current limitations: role hierarchy (for -> example `admin` inheriting `moderator`) is accepted in YAML but not yet applied -> during enforcement -- only exact role matches are checked; and conditional or -> ownership rules require a v0.2 spec, because v0.1 specs do not parse `when` rules. +> `stackform run` dev server alike. A request that no rule admits gets 401 when it +> carries no valid credentials and 403 otherwise; a missing record answers 404 +> before ownership is checked. ## URLs in this walkthrough @@ -35,8 +32,8 @@ the `/v1` segment (`PATCH /api/posts/{id}`). ## How roles are defined -Roles are an ordinary `enum` field on the auth entity, named by `auth.roles`. There -is no separate roles block to enable them. For the blog `user` entity: +Roles are an ordinary `enum` field on the auth entity, named by `auth.roles`, and a +`roles:` block adds their hierarchy. For the blog `user` entity: ```yaml user: @@ -46,6 +43,12 @@ user: identifier: email via: password roles: role + roles: + values: [admin, moderator, user] + default: user + hierarchy: + admin: [moderator] + moderator: [user] fields: email: { type: string, required: true, unique: true } displayName: { type: string, required: true } @@ -55,15 +58,11 @@ user: default: user ``` -`auth.roles: role` points at the `role` enum field. At login, the role stored on the -user record is loaded into the principal and compared (exact match) against the role -named in each access rule. A new user gets a role only through the enum field's -`default:` -- the `role` field is otherwise stripped from create/update payloads, so -clients cannot self-assign a role. - -An entity-level `roles: {values, default, hierarchy}` block (separate from the field) -is not wired in v0.2: the schema accepts it but the transformer ignores it. Define -roles via the enum field plus `auth.roles` as shown above. +`auth.roles: role` points at the `role` enum field. On every request the role stored +on the user record is loaded into the principal, together with the roles its +hierarchy includes, and compared against the role named in each access rule. A caller +sets the `role` field only to a role it holds: a public `register` always yields +`user`, and only an `admin` can make another user an `admin`. ## Rule-by-rule breakdown @@ -88,7 +87,7 @@ update: when: principal.sub equals document.authorId ``` -Rule 1: any user whose `role` is exactly `moderator` passes. +Rule 1: any user holding `moderator` passes, which includes every `admin`. Rule 2: otherwise, if the authenticated user's `sub` matches the post's `authorId`, allow (the author editing their own post). If neither rule matches, deny (403). If the post does not exist, the request gets 404 @@ -142,9 +141,8 @@ delete: { access: admin } ``` Reads are `public` (inherited from the controller-level `access: public`). Writes -require the exact role `admin`. A `moderator` does NOT satisfy `access: admin`: only -exact role matches are enforced, so there is no inheritance from `admin` down to -`moderator` or up from `moderator` to `admin`. +require the role `admin`. A `moderator` does NOT satisfy `access: admin`: the +hierarchy runs downward only, from `admin` to `moderator` to `user`. ## when-expression syntax @@ -154,9 +152,9 @@ keywords (not symbols): - Operators: `equals`, `not equals`, `in`, `not in`, `and`, `or`, `not`. - Use the word `equals`, never `==`. Keywords are case-insensitive. - Operands are field references (`principal.sub`, `document.authorId`) or - single-quoted strings (`'admin'`). There are no boolean literals and no parentheses - grouping. -- `principal` alone resolves to the subject id (the JWT `sub`). Stored fields on the + single-quoted strings (`'admin'`). There are no boolean literals; parentheses group + clauses. +- `principal.sub` is the subject id (the JWT `sub`). Stored fields on the authenticated user are read through `principal.record.` (for example `principal.record.email`). - `document.` reads the fetched route record. @@ -199,7 +197,7 @@ Here `author` and `post` are the symbol names used in `when`; `posts by id` load Rules in an `access:` list are evaluated top to bottom; the first matching rule wins. For the posts update rule: -1. Is the principal's role exactly `moderator`? -> allow. +1. Does the principal hold `moderator` (directly or as an `admin`)? -> allow. 2. Does `principal.sub` equal `document.authorId`? -> allow. 3. No rule matched -> deny (403). diff --git a/docs/v0.2/LearnByExample/multi-tenant-saas/01-overview.md b/docs/v0.2/LearnByExample/multi-tenant-saas/01-overview.md index 9e136852..ffc6a716 100644 --- a/docs/v0.2/LearnByExample/multi-tenant-saas/01-overview.md +++ b/docs/v0.2/LearnByExample/multi-tenant-saas/01-overview.md @@ -29,16 +29,13 @@ So a request to the orgs collection is `GET /api/v1/orgs`, login is - Resources scoped to organizations - Ownership/ABAC `when` rules that compare the request principal against the fetched record -> **Enforcement status** +> **Enforcement** > -> Roles (exact match), `:*` scoping, and ownership/ABAC `when` rules +> Roles, role hierarchies, `:*` scoping, and ownership/ABAC `when` rules > are enforced by the generated FastAPI backend, the generated DRF backend, and the -> `stackform run` dev server alike. A request that fails a rule gets 401 (missing or -> invalid credentials), 403 (authenticated but not allowed), or 404 (record not -> found, checked before ownership). Two current limitations: role hierarchy (for -> example `admin` inheriting `moderator`) is accepted in YAML but not yet applied -> during enforcement -- only exact role matches are checked; and conditional or -> ownership rules require a v0.2 spec, because v0.1 specs do not parse `when` rules. +> `stackform run` dev server alike. A request that no rule admits gets 401 when it +> carries no valid credentials and 403 otherwise; a missing record answers 404 +> before ownership is checked. ## Entities @@ -135,11 +132,8 @@ build with `ACCESS_UNKNOWN_ENTITY`. Use the list form above. ## Tiered API clients -The spec carries a standalone `roles:` block on `apiClient` (free, premium, -enterprise) with a hierarchy. That entity-level `roles:` block is not consumed in -v0.2, so the tier hierarchy is inert: an `enterprise` client does not automatically -satisfy `access: apiClient:premium`. To make client tiers work as enforced roles, -declare an enum field and point `auth.roles` at it: +Client tiers (free, premium, enterprise) are the roles of `apiClient`: an enum +field named by `auth.roles`, with a `roles:` block that ranks them: ```yaml apiClient: @@ -148,6 +142,12 @@ apiClient: identifier: clientId via: apiKey roles: tier + roles: + values: [free, premium, enterprise] + default: free + hierarchy: + enterprise: [premium] + premium: [free] fields: clientId: { type: string, required: true, unique: true } name: { type: string, required: true } @@ -157,9 +157,8 @@ apiClient: default: free ``` -A rule like `access: apiClient:enterprise` then matches a client whose `tier` is -exactly `enterprise`. Cross-tier inheritance is not applied (see the enforcement note -above). +A rule like `access: apiClient:premium` then matches a client whose `tier` is +`premium` or `enterprise`. ## Ownership rules and the document caveat diff --git a/docs/v0.2/LearnByExample/multi-tenant-saas/02-spec.md b/docs/v0.2/LearnByExample/multi-tenant-saas/02-spec.md index a0b7f4f0..5d419ffd 100644 --- a/docs/v0.2/LearnByExample/multi-tenant-saas/02-spec.md +++ b/docs/v0.2/LearnByExample/multi-tenant-saas/02-spec.md @@ -18,16 +18,13 @@ served under `/api/v1/...` (for example `POST /api/v1/users/login`). The two MongoDB settings are read from `MONGO_URI` / `MONGO_DB` at build time, with the inline defaults used when those variables are unset. -> **Enforcement status** +> **Enforcement** > -> Roles (exact match), `:*` scoping, and ownership/ABAC `when` rules +> Roles, role hierarchies, `:*` scoping, and ownership/ABAC `when` rules > are enforced by the generated FastAPI backend, the generated DRF backend, and the -> `stackform run` dev server alike. A request that fails a rule gets 401 (missing or -> invalid credentials), 403 (authenticated but not allowed), or 404 (record not -> found, checked before ownership). Two current limitations: role hierarchy (for -> example `admin` inheriting `moderator`) is accepted in YAML but not yet applied -> during enforcement -- only exact role matches are checked; and conditional or -> ownership rules require a v0.2 spec, because v0.1 specs do not parse `when` rules. +> `stackform run` dev server alike. A request that no rule admits gets 401 when it +> carries no valid credentials and 403 otherwise; a missing record answers 404 +> before ownership is checked. ```yaml version: "0.2" @@ -71,6 +68,12 @@ entities: identifier: clientId via: apiKey roles: tier + roles: + values: [free, premium, enterprise] + default: free + hierarchy: + enterprise: [premium] + premium: [free] fields: clientId: { type: string, required: true, unique: true } name: { type: string, required: true } @@ -192,12 +195,10 @@ every route that uses it. **Tier roles come from an enum field.** `auth.roles: tier` points the `apiClient` entity at its `tier` enum field; that is what makes `access: apiClient:enterprise` -meaningful. The standalone entity-level `roles: { values, default, hierarchy }` block -that earlier drafts used is not consumed in v0.2 -- only `auth.roles: ` -wires a role. New clients get the `tier` field's `default: free` only when the create -payload omits `tier`. Note that tier matching is exact: there is no inheritance, so -`apiClient:enterprise` does not also accept `premium` (see the Enforcement status -note above on hierarchy). +meaningful. The `roles:` block ranks the tiers, so `apiClient:premium` also accepts +an `enterprise` client, while `apiClient:enterprise` accepts only `enterprise`. A +create stores the tier it names only when the caller holds that tier itself; +otherwise the client gets `free`. **Ownership rules see only the route's own record.** A rule with `given.caller: identity:user` plus `given.document: orgs` (or `document: resources`) and @@ -248,13 +249,8 @@ authenticate by header, not by exchanging credentials for a token). **Current limitations to keep in mind.** -- The standalone entity-level `roles:` block is unconsumed; wire roles via - `auth.roles: `. -- Role hierarchy is parsed but not enforced -- only exact role matches apply. - Access must be a YAML list; the inline bracket-string form is not supported. - Ownership/`document` rules see only the route's own fetched record; there is no cross-collection document fetch. -- `filter`, `writeFields`, and `reason` keys on a rule are accepted but currently - have no effect. - `unique: true` creates a datastore constraint or index; duplicate writes return `409 Conflict`. diff --git a/docs/v0.2/LearnByExample/request-access/01-overview.md b/docs/v0.2/LearnByExample/request-access/01-overview.md index 4ed14441..aaf282a1 100644 --- a/docs/v0.2/LearnByExample/request-access/01-overview.md +++ b/docs/v0.2/LearnByExample/request-access/01-overview.md @@ -1,6 +1,6 @@ --- title: "Overview" -description: An organization-membership API that authorizes a create against its own request body and a path parameter, using a DTO-typed body and request.path.orgId. +description: An organization-membership API that authorizes a create against its own request body and a path parameter, using a DTO-typed request binding and request.path.orgId. --- An organization-membership API that authorizes a `create` against its own request @@ -12,70 +12,70 @@ path parameter (`orgId`, an OpenAPI `in: path` parameter) and a JSON body. The access rule reads both: - `req.path.orgId` — the organization the caller is acting on. -- `req.body.role` — the role being assigned, validated against a DTO. +- `req.body.orgId` and `req.body.role` — the organization and role being assigned, + validated against a DTO. The full spec is on the next page. -> **Enforcement status** +> **Enforcement** > -> Roles (exact match), `:*` scoping, and ownership/ABAC `when` rules +> Roles, role hierarchies, `:*` scoping, and ownership/ABAC `when` rules > are enforced by the generated FastAPI backend, the generated DRF backend, and the -> `stackform run` dev server alike. A request that fails a rule gets 401 (missing or -> invalid credentials), 403 (authenticated but not allowed), or 404 (record not -> found, checked before ownership). Two current limitations: role hierarchy (for -> example `admin` inheriting `moderator`) is accepted in YAML but not yet applied -> during enforcement -- only exact role matches are checked; and conditional or -> ownership rules require a v0.2 spec, because v0.1 specs do not parse `when` rules. +> `stackform run` dev server alike. A request that no rule admits gets 401 when it +> carries no valid credentials and 403 otherwise; a missing record answers 404 +> before ownership is checked. ## What this demonstrates -- A top-level `dto:` shape (`createMembership`) used to validate the request body - before authorization runs. -- A typed body binding: `given: { member: request.body:createMembership }`. -- A path-parameter binding read in `when:` via `req.path.orgId`, where `orgId` is - the `{orgId}` token from the route path. -- The create gap closed: a `create` rule reasoning about the payload itself, which - document rules can never do (create has no fetched record). +- A top-level `dto:` shape (`createMembership`) that validates the request body. +- A typed whole-request binding: `given: { req: request:createMembership }`, whose + `req.body` is validated against the DTO and whose `req.path` holds the path + parameters. +- A path-parameter read in `when:` via `req.path.orgId`, where `orgId` is the + `{orgId}` token from the route path. +- A `create` rule reasoning about the payload itself, which document rules can never + do (create has no fetched record). ## The route and its parameters -The `membership` controller declares an explicit `create` route under a base path -that nests the organization id: +The `memberships` controller overrides the `create` operation's path to nest the +organization id: ``` POST /api/v1/orgs/{orgId}/members ``` -`{orgId}` is an OpenAPI `in: path` parameter. `request.path.orgId` reads exactly -this token; naming a token the route does not declare fails generation with +`{orgId}` is an OpenAPI `in: path` parameter. `req.path.orgId` reads exactly this +token; naming a token the route does not declare fails generation with `REQUEST_PATH_PARAM_NOT_DECLARED`. ## The access rule ```yaml create: + path: /{orgId}/members access: - - org:admin # org admins assign any role + - user:admin # admins assign any role - given: principal: identity:user - org: request.path - member: request.body:createMembership + req: request:createMembership when: - - org.path.orgId equals principal.record.orgId - - member.body.role not equals 'admin' + - req.path.orgId equals principal.record.orgId + - req.body.orgId equals req.path.orgId + - req.body.role not equals 'admin' ``` Read top-to-bottom: -1. An `org:admin` principal is allowed outright — no body or path inspection. +1. A `user:admin` principal is allowed outright — no body or path inspection. 2. Otherwise the caller must belong to the organization in the path - (`org.path.orgId equals principal.record.orgId`) **and** must not be assigning - the `admin` role (`member.body.role not equals 'admin'`). The list form ANDs the - two conditions. + (`req.path.orgId equals principal.record.orgId`), the body must name that + organization (`req.body.orgId equals req.path.orgId`), **and** the caller must + not be assigning the `admin` role (`req.body.role not equals 'admin'`). The list + form ANDs the three conditions. -Because `member` binds `request.body:createMembership`, the body is validated -against the DTO **before** the rule runs. A body missing `role`, or with a `role` -outside the DTO's enum, returns 422 — the access rule never executes. +Because `req` binds `request:createMembership`, the body is validated against the +DTO. A body missing `role`, or with a `role` outside the DTO's enum, returns 422. ## The DTO @@ -83,22 +83,28 @@ outside the DTO's enum, returns 422 — the access rule never executes. dto: createMembership: fields: + orgId: { type: ref, ref: org, required: true } + userId: { type: ref, ref: user, required: true } role: type: enum values: [admin, member, viewer] required: true + note: + type: string + maxLength: 280 ``` A DTO is a request shape with validators only — no id, no persistence. See -[DTOs](../../Reference/dto/overview.md). `type: id`, `unique`, and `format` are -rejected inside a DTO with `DTO_FORBIDS_PERSISTENCE_ATTR`. +[DTOs](../../Reference/dto/overview.md). `type: id` and `unique` are rejected inside +a DTO with `SCHEMA_VALIDATION_ERROR`. ## Endpoints | Method | Path | Access | |--------|------|--------| -| POST | /api/v1/orgs/{orgId}/members | `org:admin`, or an org member assigning a non-admin role | -| GET | /api/v1/orgs/{orgId}/members | `org:*` | +| POST | /api/v1/orgs/{orgId}/members | `user:admin`, or a member of `orgId` assigning a non-admin role in it | +| POST | /api/v1/users/register | public | +| POST | /api/v1/users/login | public | ## See Also diff --git a/docs/v0.2/LearnByExample/request-access/02-spec.md b/docs/v0.2/LearnByExample/request-access/02-spec.md index 56829214..7ad57b74 100644 --- a/docs/v0.2/LearnByExample/request-access/02-spec.md +++ b/docs/v0.2/LearnByExample/request-access/02-spec.md @@ -1,23 +1,28 @@ --- title: "Spec" -description: The full YAML for the organization-membership API with a DTO-typed create body and a request.path.orgId access rule. +description: The full YAML for the organization-membership API with a DTO-typed request binding and a request.path.orgId access rule. --- -The full spec. The `dto:` root declares the request shape; the `membership` -controller declares an explicit nested route whose `{orgId}` token is read in the -access rule through `request.path.orgId`. +The full spec. The `dto:` root declares the request shape; the `memberships` +controller exposes only `create`, on a route whose `{orgId}` token the access rule +reads through `req.path.orgId`. ```yaml +version: "0.2" + api: basePath: /api version: v1 operations: auth: + register: true login: true dto: createMembership: fields: + orgId: { type: ref, ref: org, required: true } + userId: { type: ref, ref: user, required: true } role: type: enum values: [admin, member, viewer] @@ -31,13 +36,12 @@ entities: type: auth auth: identifier: email - secret: password + via: password roles: role fields: id: { type: id } - email: { type: string, required: true, format: email } - password: { type: string, required: true } - orgId: { type: ref, ref: org, required: true } + email: { type: string, required: true, unique: true, format: email } + orgId: { type: ref, ref: org } role: type: enum values: [admin, member, viewer] @@ -59,83 +63,109 @@ entities: required: true note: { type: string } +repositories: + users: { entity: user } + memberships: { entity: membership } + +services: + users: {} + memberships: {} + controllers: - membership: - service: membership - base_path: /orgs/{orgId}/members - access: "*" + users: + service: users + base_path: /users + access: user:admin + operations: + register: { access: public } + login: { access: public } + + memberships: + service: memberships + base_path: /orgs operations: - list: - access: org:* + list: false + get: false + update: false + replace: false + delete: false create: + path: /{orgId}/members access: - - org:admin + - user:admin - given: principal: identity:user - org: request.path - member: request.body:createMembership + req: request:createMembership when: - - org.path.orgId equals principal.record.orgId - - member.body.role not equals 'admin' + - req.path.orgId equals principal.record.orgId + - req.body.orgId equals req.path.orgId + - req.body.role not equals 'admin' ``` ## Walkthrough -### The DTO validates the body first +### The DTO validates the body -`member: request.body:createMembership` binds the request body and validates it -against the `createMembership` DTO before authorization. The DTO requires `role` -to be one of `admin`, `member`, `viewer`. A body that omits `role` or sends an -out-of-set value returns 422 — the rule never runs. +`req: request:createMembership` binds the whole request and validates its body +against the `createMembership` DTO. The DTO requires `orgId`, `userId`, and a `role` +that is one of `admin`, `member`, `viewer`, and rejects any other key. A body that +omits `role` or sends an out-of-set value returns 422. -The DTO is a request shape only: it has no `id`, no repository, no route. If you -add `type: id`, `unique`, or `format` to a DTO field, generation fails with -`DTO_FORBIDS_PERSISTENCE_ATTR`. +The DTO is a request shape only: it has no `id`, no repository, no route. A DTO +field that declares `type: id` or `unique` fails generation with +`SCHEMA_VALIDATION_ERROR`. ### The path parameter is read from the route template -The controller's `base_path` is `/orgs/{orgId}/members`, so the composed create -route is `POST /api/v1/orgs/{orgId}/members`. `{orgId}` is an OpenAPI `in: path` -parameter. The rule binds the path map with `org: request.path` and reads the -token in `when:` as `org.path.orgId`. +The `create` operation's `path` is `/{orgId}/members` under `base_path: /orgs`, so +the composed route is `POST /api/v1/orgs/{orgId}/members`. `{orgId}` is an OpenAPI +`in: path` parameter, read in `when:` as `req.path.orgId`. A `base_path` itself may +not hold a path token (`CONTROLLER_BASE_PATH_INVALID`). -If you wrote `org.path.tenantId` (a token the route does not declare), generation +If you wrote `req.path.tenantId` (a token the route does not declare), generation would fail with `REQUEST_PATH_PARAM_NOT_DECLARED`. -### The two conditions +### The three conditions ```yaml when: - - org.path.orgId equals principal.record.orgId - - member.body.role not equals 'admin' + - req.path.orgId equals principal.record.orgId + - req.body.orgId equals req.path.orgId + - req.body.role not equals 'admin' ``` -The list form ANDs both. The caller must belong to the organization named in the -URL, and may not assign the `admin` role. An `org:admin` principal skips both -checks via the first rule in the list. +The list form ANDs them. The caller must belong to the organization named in the +URL, the body must name that same organization, and the caller may not assign the +`admin` role. A `user:admin` principal skips all three through the first rule in +the list. ### Why a document rule would not work here `create` has no fetched record, so a `document:` binding never resolves for it. -The data the rule needs — the path's `orgId` and the body's `role` — lives only on -the request. That is exactly what the request bindings expose. See +The data the rule needs (the path's `orgId` and the body's `orgId` and `role`) +lives only on the request. That is exactly what the request bindings expose. See [Access control: Request access](../../Reference/controllers/access-control.md). +### Accounts + +Registration is public and takes `orgId` from the body; every other `users` route +requires `user:admin`. A new user's `role` is `member`: a caller sets `role` only +to a role it holds itself. + ## Try it -A member of org `acme` adding a viewer to their own org: +A member of an organization adding a viewer to it: ```bash -curl -X POST http://localhost:8000/api/v1/orgs/acme/members \ - -H "Authorization: Bearer $TOKEN" \ +curl -X POST "http://localhost:8000/api/v1/orgs/$ORG/members" \ + -u ann@example.com:pw \ -H "Content-Type: application/json" \ - -d '{"userId": "u-123", "role": "viewer"}' + -d "{\"orgId\": \"$ORG\", \"userId\": \"$USER_ID\", \"role\": \"viewer\"}" ``` -The same member trying to assign `admin` is denied with 403; a body with an -invalid `role` is rejected with 422 before the rule runs; a member acting on a -different org's URL is denied because `org.path.orgId` does not match their -`principal.record.orgId`. +The same member trying to assign `admin` gets 403; a body with an invalid `role` +gets 422; a member acting on a different organization's URL, or naming a different +`orgId` in the body, gets 403; a request without credentials gets 401. ## See Also diff --git a/docs/v0.2/LearnByExample/todo/01-overview.md b/docs/v0.2/LearnByExample/todo/01-overview.md index 52a0fe8f..8dd71efc 100644 --- a/docs/v0.2/LearnByExample/todo/01-overview.md +++ b/docs/v0.2/LearnByExample/todo/01-overview.md @@ -26,16 +26,13 @@ api: With that block the routes are prefixed `/api/v1`. Drop the `version:` line to get the spec's default `/api` prefix instead. -> **Enforcement status** +> **Enforcement** > -> Roles (exact match), `:*` scoping, and ownership/ABAC `when` rules +> Roles, role hierarchies, `:*` scoping, and ownership/ABAC `when` rules > are enforced by the generated FastAPI backend, the generated DRF backend, and the -> `stackform run` dev server alike. A request that fails a rule gets 401 (missing or -> invalid credentials), 403 (authenticated but not allowed), or 404 (record not -> found, checked before ownership). Two current limitations: role hierarchy (for -> example `admin` inheriting `moderator`) is accepted in YAML but not yet applied -> during enforcement -- only exact role matches are checked; and conditional or -> ownership rules require a v0.2 spec, because v0.1 specs do not parse `when` rules. +> `stackform run` dev server alike. A request that no rule admits gets 401 when it +> carries no valid credentials and 403 otherwise; a missing record answers 404 +> before ownership is checked. ## Entities diff --git a/docs/v0.2/LearnByExample/todo/02-spec.md b/docs/v0.2/LearnByExample/todo/02-spec.md index 18b83857..0d5a4892 100644 --- a/docs/v0.2/LearnByExample/todo/02-spec.md +++ b/docs/v0.2/LearnByExample/todo/02-spec.md @@ -196,16 +196,13 @@ and point `auth.roles: role` at it, then add `- admin` to the relevant access lists. The standalone entity-level `roles:` block is parsed but not wired in v0.2 -- use `auth.roles: ` instead. -> **Enforcement status** +> **Enforcement** > -> Roles (exact match), `:*` scoping, and ownership/ABAC `when` rules +> Roles, role hierarchies, `:*` scoping, and ownership/ABAC `when` rules > are enforced by the generated FastAPI backend, the generated DRF backend, and the -> `stackform run` dev server alike. A request that fails a rule gets 401 (missing or -> invalid credentials), 403 (authenticated but not allowed), or 404 (record not -> found, checked before ownership). Two current limitations: role hierarchy (for -> example `admin` inheriting `moderator`) is accepted in YAML but not yet applied -> during enforcement -- only exact role matches are checked; and conditional or -> ownership rules require a v0.2 spec, because v0.1 specs do not parse `when` rules. +> `stackform run` dev server alike. A request that no rule admits gets 401 when it +> carries no valid credentials and 403 otherwise; a missing record answers 404 +> before ownership is checked. ## Running it diff --git a/docs/v0.2/Reference/auth/apikey-provider.md b/docs/v0.2/Reference/auth/apikey-provider.md index 099018ab..8348ab90 100644 --- a/docs/v0.2/Reference/auth/apikey-provider.md +++ b/docs/v0.2/Reference/auth/apikey-provider.md @@ -64,22 +64,19 @@ at `/api/v1/my-collection`. `sub` set to the record's id field, `record` set to the full document, and `roles` taken from the entity's roles field (see Roles below). -> **Enforcement status** +> **Enforcement** > -> Roles (exact match), `:*` scoping, and ownership/ABAC `when` rules +> Roles, role hierarchies, `:*` scoping, and ownership/ABAC `when` rules > are enforced by the generated FastAPI backend, the generated DRF backend, and the -> `stackform run` dev server alike. A request that fails a rule gets 401 (missing or -> invalid credentials), 403 (authenticated but not allowed), or 404 (record not -> found, checked before ownership). Two current limitations: role hierarchy (for -> example `admin` inheriting `moderator`) is accepted in YAML but not yet applied -> during enforcement -- only exact role matches are checked; and conditional or -> ownership rules require a v0.2 spec, because v0.1 specs do not parse `when` rules. +> `stackform run` dev server alike. A request that no rule admits gets 401 when it +> carries no valid credentials and 403 otherwise; a missing record answers 404 +> before ownership is checked. ## Options | Field | Required | Type | Description | |-------|----------|------|-------------| -| `identifier` | effectively required | string (field name) | Names the field that identifies the client (for example `clientId`). Not used for API key matching, but conventionally declared `unique: true`. | +| `identifier` | yes | string (field name) | Names the field that identifies the client (for example `clientId`; schema validation error when missing, unless the `via` binding sets `identifier`). Not used for API key matching, but conventionally declared `unique: true`. | | `via` | yes | string or list | Must include `apiKey`. A list (for example `[apiKey, password]`) enables more than one credential type on the same entity. | | `roles` | no | string (field name) | Names an `enum` field whose value becomes the principal's role. See Roles. | @@ -174,10 +171,9 @@ which the backend tries before treating a bearer value as an API key. ## Roles -The entity-level `roles: { values, default, hierarchy }` block is parsed but not wired -into the backend in v0.2 -- it does not assign a default role on create and its -hierarchy is inert. To attach a role to an API client, declare an `enum` field and -point `auth.roles` at it: +To attach a role to an API client, declare an `enum` field and point `auth.roles` at +it, or declare managed roles in a `roles:` block (see +[Roles overview](../roles/overview.md)): ```yaml entities: @@ -199,8 +195,8 @@ entities: ``` The principal's role is read from the named field (`tier` here). Access rules then -match it exactly with `entity:role`, for example `apiClient:enterprise`. Role -hierarchy is not applied during enforcement; only exact matches pass. +match it with `entity:role`, for example `apiClient:enterprise`. A `roles.hierarchy` +lets one tier include others (see [Role hierarchy](../roles/hierarchy.md)). ## Errors diff --git a/docs/v0.2/Reference/auth/auth-required-by-default.md b/docs/v0.2/Reference/auth/auth-required-by-default.md index d2e76973..82e64e1b 100644 --- a/docs/v0.2/Reference/auth/auth-required-by-default.md +++ b/docs/v0.2/Reference/auth/auth-required-by-default.md @@ -1,6 +1,6 @@ --- title: "Authentication required by default" -description: Once any auth entity exists, every non-public route requires a valid principal. Only access public opts out. +description: Every route not declared access public requires a principal its access value admits. --- Once your spec contains any `type: auth` entity, the generated app protects every @@ -10,16 +10,11 @@ you to mark routes public, not to mark them protected. ## The rule -A route is protected (requires a valid principal) when both are true: - -- The route is not declared `access: public`. -- An identity collection exists in the spec (at least one `type: auth` entity). - -Internally the generated app computes this as `needs_auth = not is_public and -has_identity`. The second half matters: with no auth entity in the spec, there is -nothing to authenticate against, so even a non-public route stays open. Add a single -`type: auth` entity and every route that is not `access: public` immediately starts -requiring credentials. +Every route not declared `access: public` requires a principal that satisfies its +access value. A request without valid credentials gets 401, and an authenticated +request the access value does not admit gets 403. A spec with no `type: auth` +entity has no way to authenticate, so its non-public routes answer every request +with 401: give such routes `access: public`, or add an auth entity. ```yaml version: "0.2" @@ -44,9 +39,9 @@ entities: title: { type: string, required: true } ``` -With the `myEntity` entity present, `GET /api/v1/my-collection` now returns 401 -unless the caller presents valid credentials. Remove the `myEntity` entity and the -same route is open to anyone. +The repository, service and controller of `myCollection` are left out above; once +its controller sets `access: "*"`, `GET /api/v1/my-collection` returns 401 unless +the caller presents valid credentials. ## Effective access resolution @@ -69,8 +64,8 @@ flowchart TD P3 -- "yes, role match" --> OK ``` -The default FastAPI build, the DRF build, and `stackform run` all follow this same -resolution. A request with no credentials on a protected route stops at the "401 +`stackform run` and every generated backend (FastAPI, DRF, raw ASGI) follow this +same resolution. A request with no credentials on a protected route stops at the "401 if no principal" node; an authenticated request that does not satisfy the role or `:*` scope gets 403. @@ -84,55 +79,55 @@ vocabulary: | `public` | No authentication. The only way to opt a route out of protection. | | `"*"` | Any valid principal from any auth entity. | | `:*` | Any principal whose identity collection is `` -- any member of that auth entity. | -| `:` | A principal of `` whose role (exact match) is ``. | +| `:` | A principal of `` holding ``, directly or through the role hierarchy. | | `` | Bare role; allowed only when a single auth entity exists (otherwise `ACCESS_AMBIGUOUS_ROLE`). | -Roles come from the entity's enum field named by `auth.roles: ` (with -fallback to a `roles` or `role` field). The standalone entity-level `roles:` block is -not wired in v0.2. See [Roles overview](../roles/overview.md). +Roles come from the entity-level `roles:` block (managed roles) or from the enum +field named by `auth.roles`. See [Roles overview](../roles/overview.md). -## Minified controller default +## Controllers without `access:` -A controller written in its minified form (just a repository reference, no explicit -`access:`) defaults to `access: public`. This is the most common way a route is -left open by accident. +A controller that declares no `access:` defaults to `access: public`, so every +route it generates is open. This is the most common way a route is left open by +accident. ```yaml controllers: - myCollection: myCollection # minified -> access defaults to public + myCollection: + service: myCollection # no access -> every route is public + base_path: /my-collection ``` -If you want `myCollection` protected, declare the access explicitly: +Declare the access explicitly to protect it: ```yaml controllers: myCollection: - repository: myCollection + service: myCollection + base_path: /my-collection access: "*" ``` -You can also tighten or loosen individual operations: +Individual operations tighten or loosen the controller default: ```yaml controllers: myCollection: - repository: myCollection + service: myCollection + base_path: /my-collection access: "*" operations: list: { access: public } # reads open, writes protected - delete: { access: user:admin } + delete: { access: myEntity:admin } ``` -> **Enforcement status** +> **Enforcement** > -> Roles (exact match), `:*` scoping, and ownership/ABAC `when` rules +> Roles, role hierarchies, `:*` scoping, and ownership/ABAC `when` rules > are enforced by the generated FastAPI backend, the generated DRF backend, and the -> `stackform run` dev server alike. A request that fails a rule gets 401 (missing or -> invalid credentials), 403 (authenticated but not allowed), or 404 (record not -> found, checked before ownership). Two current limitations: role hierarchy (for -> example `admin` inheriting `moderator`) is accepted in YAML but not yet applied -> during enforcement -- only exact role matches are checked; and conditional or -> ownership rules require a v0.2 spec, because v0.1 specs do not parse `when` rules. +> `stackform run` dev server alike. A request that no rule admits gets 401 when it +> carries no valid credentials and 403 otherwise; a missing record answers 404 +> before ownership is checked. ## See Also diff --git a/docs/v0.2/Reference/auth/claim-mapping.md b/docs/v0.2/Reference/auth/claim-mapping.md index f39d740c..8005892f 100644 --- a/docs/v0.2/Reference/auth/claim-mapping.md +++ b/docs/v0.2/Reference/auth/claim-mapping.md @@ -3,22 +3,11 @@ title: "Claim Mapping and Auto-Provision" description: How an external provider's identity claims (OIDC, SAML) map to entity fields on a per-binding basis, and whether a new record is created automatically on first SSO login. --- -External providers are attached to an auth entity through `auth.bindings`. Each binding -names a configured `provider`, the local `identifier` field used to match an existing -record, an optional `claim_mapping` (provider claim -> entity field), and `auto_provision` -(create a record on first login). Claim mapping and auto-provision are properties of a -binding, not of `auth.via`. `via` is for the built-in `password` and `apiKey` providers. - -> **Enforcement status** -> -> Roles (exact match), `:*` scoping, and ownership/ABAC `when` rules -> are enforced by the generated FastAPI backend, the generated DRF backend, and the -> `stackform run` dev server alike. A request that fails a rule gets 401 (missing or -> invalid credentials), 403 (authenticated but not allowed), or 404 (record not -> found, checked before ownership). Two current limitations: role hierarchy (for -> example `admin` inheriting `moderator`) is accepted in YAML but not yet applied -> during enforcement -- only exact role matches are checked; and conditional or -> ownership rules require a v0.2 spec, because v0.1 specs do not parse `when` rules. +External providers are attached to an auth entity through binding objects in +`auth.via`. Each binding names a configured `provider`, the local `identifier` field +used to match an existing record, an optional `claim_mapping` (entity field -> +provider claim), `auto_provision` (create a record on first login) and `relations` +(fill a foreign key from claims). ## Syntax @@ -35,8 +24,8 @@ entities: type: auth auth: identifier: email - via: password - bindings: + via: + - provider: password - provider: myoidc identifier: email claim_mapping: @@ -56,14 +45,16 @@ There is no built-in inference of well-known endpoints for any provider name. ## Behavior -- **Parse time:** A binding requires `provider` and `identifier`. `claim_mapping` keys are - taken verbatim; they are not validated against `fields:`. `auto_provision` defaults to - `true`. +- **Parse time:** A binding requires `provider`, which must be `password`, `apiKey` or + a key of `providers:` (`BINDING_PROVIDER_NOT_FOUND`). `identifier` defaults to + `auth.identifier` and must name a field of the entity + (`BINDING_IDENTIFIER_FIELD_NOT_FOUND`). `claim_mapping` keys are taken verbatim. + `auto_provision` defaults to `true`. - **Runtime (SSO login):** The provider returns claims. For every `entity field: claim` - pair in `claim_mapping`, if that claim is present in the provider response, the value is - copied to the entity field. The binding's `identifier` value is used to look up an - existing record. If none is found and `auto_provision: true`, a record is created from - the mapped fields. + pair in `claim_mapping`, a present claim is converted to the type of the entity + field and copied to it; a value the field cannot hold is left out. The binding's + `identifier` value looks up an existing record. If none is found and + `auto_provision: true`, a record is created from the mapped fields. ## Options @@ -78,7 +69,9 @@ claim_mapping: ``` Only the keys you list are copied. There is no automatic mapping of identically named -claims; an unmapped claim is ignored. +claims; an unmapped claim is ignored. A claim name is read verbatim first, then as a +dotted path into nested claims (`realm_access.roles`), where a `*` segment selects the +sole entry of an object. If the binding's `identifier` field is not produced by `claim_mapping`, the engine fills it from a fallback so the user can still be matched: @@ -94,12 +87,30 @@ attribute's first value is used). | Value | Behavior | |-------|----------| -| `true` (default) | If no record matches the binding `identifier`, create one from the mapped claim values. The generated id is a `uuid4`. No role is assigned at provision time. | +| `true` (default) | If no record matches the binding `identifier`, create one from the mapped claim values. No role is assigned at provision time. | | `false` | If no record matches, return 403 with detail `User not found and auto-provisioning is disabled`. The user must already exist. | -A provisioned record contains only the mapped claim fields plus the generated id. Any -field with a YAML `default:` is not auto-filled by the SSO path, and no role is set, so a -default role for SSO users must come from another flow. +A provisioned record contains only the mapped claim fields, the filled relations and +a generated id. A field's YAML `default:` is not applied by the SSO path, and no role +is set, so a role for SSO users must come from another flow. + +### `relations` + +Fills a foreign key of the identity record with the id of a record in another +collection whose fields match claims: + +```yaml +relations: + orgId: # foreign key on the identity entity + collection: orgs + match: + slug: org # orgs.slug must equal the "org" claim +``` + +The key must be a field of the entity (`BINDING_RELATION_FIELD_NOT_FOUND`), +`collection` a declared collection (`BINDING_RELATION_COLLECTION_NOT_FOUND`), and each +`match` key a field of that collection (`BINDING_RELATION_MATCH_FIELD_NOT_FOUND`). +When a matched claim is absent or no record matches, the foreign key is set to `null`. ## Examples @@ -125,8 +136,8 @@ entities: type: auth auth: identifier: email - via: password - bindings: + via: + - provider: password - provider: myoidc identifier: email claim_mapping: @@ -147,7 +158,7 @@ On first SSO login (the browser visits `GET /api/v1/my-collection/auth/myoidc`): 2. `email`, `name`, and `picture` are read from the claims; `displayName` and `avatarUrl` are populated via `claim_mapping`, and `email` is the identifier. 3. No record matches that `email`, so `auto_provision: true` creates one with `email`, - `displayName`, `avatarUrl`, and a generated `uuid4` id. + `displayName`, `avatarUrl`, and a generated id. 4. A single-use login code is minted and the browser is redirected to `{FRONTEND_URL}/auth/callback?code=`; the SPA redeems it at `/auth/exchange` for the application JWT. Subsequent logins match the existing record. @@ -157,8 +168,8 @@ On first SSO login (the browser visits `GET /api/v1/my-collection/auth/myoidc`): ```yaml auth: identifier: email - via: password - bindings: + via: + - provider: password - provider: myoidc identifier: email claim_mapping: @@ -172,14 +183,13 @@ record matches the binding `identifier`, the SSO callback returns 403 with detai ## What a successful SSO login returns -The two SSO callbacks differ in shape: +Both callbacks end the same way: the engine mints a single-use login code and +redirects to `{FRONTEND_URL}/auth/callback?code=` (when `FRONTEND_URL` is unset, +the request base URL is used). The SPA then `POST`s the code to `/auth/exchange` and +receives the application's own JWT, not the provider token. -- **OIDC** (`GET .../auth/{provider}/callback?code=...`): on success, the engine mints a - single-use login code and issues a 302 redirect to `{FRONTEND_URL}/auth/callback?code=` - (when `FRONTEND_URL` is unset, the request base URL is used). The SPA then `POST`s the code - to `/auth/exchange` and receives the application's own JWT, not the provider token. -- **SAML** (`POST .../auth/{provider}/callback`): on success, the engine returns JSON - `{"token": "", "user": {...}}` with `_id` and `_auth` stripped from the user object. +- **OIDC** (`GET .../auth/{provider}/callback?code=...`) answers with a 302 redirect. +- **SAML** (`POST .../auth/{provider}/callback`) answers with a 303 redirect. With `api: { basePath: /api, version: v1 }`, the OIDC routes are `/api/v1/{collection}/auth/{provider}` and `/api/v1/{collection}/auth/{provider}/callback`, @@ -211,20 +221,21 @@ providers: ## Edge Cases - **Missing claim from provider.** If a mapped claim is absent from the provider response, - the corresponding entity field is simply omitted from the created record (not set to - `null`). + the corresponding entity field is omitted from the created record (not set to `null`). - **Mapping to a non-existent entity field.** `claim_mapping` keys are not validated against `fields:` at parse time. The mapped value is written to the record verbatim, but a field - not declared in the Pydantic model is stripped from CRUD API responses. + not declared on the entity is stripped from CRUD API responses. + +- **`identifier` not in the claims.** The engine fills the identifier from the fallback + (OIDC: `email`/`login`/`sub`; SAML: `NameID`). If none yields a value the identifier + field can hold, the sign-in is refused with 401. -- **`identifier` not in `claim_mapping`.** The engine fills the identifier from the fallback - (OIDC: `email`/`login`/`sub`; SAML: `NameID`). If none of those are present either, the - lookup uses an empty value and likely fails to match. +- **Existing user.** If a record already matches the binding `identifier`, every + sign-in brings its mapped fields and relations up to date; its id never changes. -- **Existing user.** If a record already matches the binding `identifier`, that record is - used and a fresh application JWT is issued. Mapped claims are NOT written back to the - existing record; only new records receive claim data. +- **Unique conflict.** A sign-in that would give the record a unique value another + record already holds is refused with 401. ## Errors @@ -233,7 +244,9 @@ providers: | OIDC token exchange fails | 401 | `Token exchange failed` | | OIDC userinfo fetch fails | 401 | `Failed to fetch user info` | | Provider token already expired | 401 | `Token expired` | -| SAML assertion error / not authenticated | 401 | `SAML error: ...` / `SAML authentication failed` | +| SAML response invalid or not authenticated | 401 | `SAML authentication failed: ...` | +| No claim yields a usable identifier | 401 | `The provider's claims carry no usable ''` | +| Signing in would duplicate a unique value | 401 | | | No matching record and `auto_provision: false` | 403 | `User not found and auto-provisioning is disabled` | ## See Also diff --git a/docs/v0.2/Reference/auth/login-and-sso.md b/docs/v0.2/Reference/auth/login-and-sso.md index 8dd9b4b1..a3f60107 100644 --- a/docs/v0.2/Reference/auth/login-and-sso.md +++ b/docs/v0.2/Reference/auth/login-and-sso.md @@ -49,7 +49,7 @@ curl -X POST http://localhost:8000/api/v1/my-collection/login \ -d '{"email": "alice@example.com", "password": "s3cr3t"}' ``` -On success the response is the token plus the matched record: +On success the response is the token, the matched record and the roles it holds: ```json { @@ -57,11 +57,14 @@ On success the response is the token plus the matched record: "user": { "id": "0190...", "email": "alice@example.com" - } + }, + "roles": ["editor"] } ``` -The `user` object excludes `password`, `_id`, and the internal `_auth` namespace. +The `user` object excludes `password`, `apiKey`, `_id`, and the internal `_auth` namespace. +`roles` lists the roles the record holds, read from the identity's roles field or, +when it declares none, from `_auth.roles`; it is empty for an identity without roles. A wrong password or unknown identifier both return 401 with an indistinguishable message (no user enumeration). @@ -86,12 +89,12 @@ The minted token carries identity only: - `sub` is the record's id-field value; access rules read it as `principal.sub`. - `identity_collection` names which auth entity the principal belongs to. - Roles are NOT in the token. On every request the app re-loads the record from - its repository by the configured id field and re-reads the role from the - entity's role field, so a role change takes effect on the next request without - re-issuing a token. + its repository by the configured id field and re-reads its roles, so a role + change takes effect on the next request without re-issuing a token. -Tokens are signed HS256 with `JWT_SECRET` and expire after `JWT_EXPIRATION_HOURS` -(default 24). See [Environment variables](../configuration/environment-variables.md). +Tokens are signed HS256 with `JWT_SECRET` and expire after `session.ttl` seconds, +or after `JWT_EXPIRATION_HOURS` (default 24) when `session.ttl` is unset. See +[Environment variables](../configuration/environment-variables.md). ## Credential resolution order @@ -113,7 +116,7 @@ If none resolve, the request is unauthenticated and protected routes return 401. ## OIDC single sign-on -Attach an OIDC provider to the auth entity through `auth.bindings` (not `via`). +Attach an OIDC provider to the auth entity through a binding object in `auth.via`. `discovery_url` is effectively required -- the app fetches the provider's `.well-known/openid-configuration` from it at request time; there is no built-in inference for "well-known" providers. @@ -131,8 +134,8 @@ entities: type: auth auth: identifier: email - via: password - bindings: + via: + - provider: password - provider: myoidc identifier: email claim_mapping: @@ -170,22 +173,30 @@ sequenceDiagram A-->>B: 302 to FRONTEND_URL/auth/callback?code= B->>A: POST /api/v1/auth/exchange {code} A->>A: redeem code (single-use), mint app JWT - A-->>B: 200 {token} + A-->>B: 200 {token, user, roles} ``` Notes on the callback: -- Claims are copied into the new/looked-up record only for keys present in - `claim_mapping`. A claim absent from the IdP response is omitted (not set to null). +- Claims are copied into the new or matched record only for keys present in + `claim_mapping`, converted to the field's type. A claim absent from the IdP + response is omitted (not set to null). Every sign-in brings a matched record's + mapped fields up to date. - If `claim_mapping` does not fill the binding's `identifier`, the app falls back to - the `email`, then `login`, then `sub` claim for the identifier value. -- A provisioned record gets a server-generated id (uuid4). -- Token exchange failure, userinfo failure, or an expired token each return 401. + the `email`, then `login`, then `sub` claim for the identifier value. Claims that + yield no usable identifier return 401. +- A provisioned record gets a server-generated id. +- Token exchange failure, userinfo failure, an expired token, or a sign-in that + would duplicate a unique value each return 401. A callback whose `state` does + not match the login that started it returns 400 (`Invalid OIDC state`). - The app JWT never travels in a redirect URL. The callback instead mints a single-use login code and redirects to `{FRONTEND_URL}/auth/callback?code=`. If `FRONTEND_URL` is unset, the app's own base URL is used. The React login page reads the `code` query parameter and `POST`s it to `/api/v1/auth/exchange` as - `{"code": ""}`, receiving `{"token": ""}` in return. + `{"code": ""}`, receiving the same body a password login answers: + `{"token": "", "user": {...}, "roles": [...]}`. With cookie session + delivery the token travels in the session cookie and the body carries + `user`, `roles` and `csrf_token`. - The login code is stored in an `_oidc_login_codes` collection in the identity collection's datastore (Mongo or SQL), lives for 60 seconds, and is redeemed atomically (delete-on-read), so a code works exactly once even across workers. @@ -230,8 +241,8 @@ entities: type: auth auth: identifier: email - via: password - bindings: + via: + - provider: password - provider: mysaml identifier: email claim_mapping: @@ -247,14 +258,18 @@ SAML routes (all under `/api/v1/{collection}/auth/{provider}`): | Method | Path | Description | |--------|------|-------------| | GET | `.../auth/{provider}` | Begin login; 302 to the IdP. | -| POST | `.../auth/{provider}/callback` | Assertion Consumer Service. Returns JSON `{token, user}`. | +| POST | `.../auth/{provider}/callback` | Assertion Consumer Service. 303 to `{FRONTEND_URL}/auth/callback?code=`. | | GET | `.../auth/{provider}/metadata` | Service-provider metadata XML. | +| GET | `.../auth/{provider}/logout` | Redirect to the IdP's single logout service. | -The ACS callback returns the app JWT and the record as JSON (the `_id` and `_auth` -fields are stripped), rather than a 302 redirect. A SAML error or a failed assertion -returns 401; `auto_provision: false` with no matching record returns 403. The -identifier falls back to the SAML NameID when `claim_mapping` does not supply it. IdP -metadata is fetched at request time from `idp_metadata_url`. +The ACS callback ends like the OIDC callback: it mints a single-use login code and +redirects to the frontend, which redeems the code at `/api/v1/auth/exchange`. A +missing or malformed `SAMLResponse` returns 400; a failed assertion, a response +that answers no pending login, or a replayed assertion returns 401; +`auto_provision: false` with no matching record returns 403. The identifier falls +back to the SAML NameID when `claim_mapping` does not supply it. IdP metadata is +fetched at request time from `idp_metadata_url`. See +[SAML provider](../providers/saml.md). ## Listing configured providers @@ -273,9 +288,9 @@ endpoint to render one SSO button per provider, alongside the password login for | Variable | Used by | Purpose | |----------|---------|---------| | `JWT_SECRET` | all login paths | HS256 signing secret. Set a strong value in production. | -| `JWT_EXPIRATION_HOURS` | all login paths | Token lifetime in hours (default 24). | +| `JWT_EXPIRATION_HOURS` | all login paths | Token lifetime in hours when `session.ttl` is unset (default 24). | | `PASSWORD_HASH_ROUNDS` | password register/login | PBKDF2 iteration count (default 390000). | -| `FRONTEND_URL` | OIDC callback | Base for the `.../auth/callback?code=` redirect. | +| `FRONTEND_URL` | OIDC and SAML callbacks | Base for the `.../auth/callback?code=` redirect. | | `_CLIENT_SECRET` | OIDC token exchange | Each confidential provider's client secret (e.g. `KEYCLOAK_CLIENT_SECRET`), a `SecretStr` setting read at run time. Emitted only when the provider declares a `client_secret`. | Provider `${VAR}` placeholders are interpolated from the environment at build time diff --git a/docs/v0.2/Reference/auth/overview.md b/docs/v0.2/Reference/auth/overview.md index 8574b156..2eae306f 100644 --- a/docs/v0.2/Reference/auth/overview.md +++ b/docs/v0.2/Reference/auth/overview.md @@ -35,37 +35,36 @@ entities: default: user ``` -Roles are an ordinary `enum` field named by `auth.roles`. The role's allowed values -and the default assigned on create come from that field's `values:` and `default:`. +A record's roles come from one of two places: -> **Note:** A standalone entity-level `roles: { values, default, hierarchy }` block is -> NOT wired in v0.2 and is silently ignored. Use `auth.roles: ` pointing -> at an `enum` field. Role hierarchy is covered under "Role hierarchy" below. +- **A role field.** `auth.roles` names an `enum` field of the entity. Its `values:` + are the roles and its `default:` is the role a new record gets. +- **Managed roles.** An entity-level `roles: { values, default, hierarchy }` block + keeps the roles in the record's internal `_auth` namespace, outside the API's + fields. See [roles overview](../roles/overview.md). ## Behavior - **Parse time:** declaring `type: auth` requires an `auth:` block (schema validation). - The `auth.identifier` and `auth.via` keys are optional in the JSON schema, but a - password or API-key provider cannot resolve a principal without them, so treat them - as effectively required. If `auth.roles` is set, the named field must exist - (`AUTH_ROLES_FIELD_NOT_FOUND`) and be of `type: enum` (`AUTH_ROLES_FIELD_NOT_ENUM`). + A `password` or `apiKey` entity must set `auth.identifier`, or carry an + `identifier` in its `via` binding (schema validation), and the identifier must be + one of its fields (`AUTH_IDENTIFIER_FIELD_NOT_FOUND`); and an `apiKey` entity must declare an + `apiKey` field (`AUTH_API_KEY_FIELD_MISSING`). If `auth.roles` is set, the named + field must exist (`AUTH_ROLES_FIELD_NOT_FOUND`) and be of `type: enum` + (`AUTH_ROLES_FIELD_NOT_ENUM`). - **Compile time:** register and login endpoints are generated only when enabled via `api.operations.auth: { register: true, login: true }` (off by default). For password-based auth, a `password` input is injected into the create model. - **Runtime:** principals authenticated via this entity receive a JWT whose `sub` is the identity record's primary-key value. The entity participates in access rules as - `:*` and `:`. - -> **Enforcement status** -> -> Roles (exact match), `:*` scoping, and ownership/ABAC `when` rules -> are enforced by the generated FastAPI backend, the generated DRF backend, and the -> `stackform run` dev server alike. A request that fails a rule gets 401 (missing or -> invalid credentials), 403 (authenticated but not allowed), or 404 (record not -> found, checked before ownership). Two current limitations: role hierarchy (for -> example `admin` inheriting `moderator`) is accepted in YAML but not yet applied -> during enforcement -- only exact role matches are checked; and conditional or -> ownership rules require a v0.2 spec, because v0.1 specs do not parse `when` rules. + `:*` and `:`, and a role also matches every role its + hierarchy includes. A request that fails every rule gets 401 when it carries no + valid credentials and 403 otherwise; a missing record answers 404 before ownership + is checked. +- **Role grants:** a caller sets a role field only to roles it holds itself. On + create, a record naming other roles gets the default role instead; on update, the + role change is dropped. Managed roles are never written through the API: a new + record gets `roles.default`. ## Options @@ -85,24 +84,25 @@ Effects: | Field | Required | Type | Description | |-------|----------|------|-------------| -| `identifier` | effectively yes | string (field name) | The field used as the login username (for password) or matched value. Not schema-enforced, but auth cannot resolve a principal without it. | -| `via` | effectively yes | string, list of strings, or list of objects | Built-in methods (`"password"`, `"apiKey"`) as strings, and/or inline provider bindings as objects (see below). | +| `identifier` | for `password` and `apiKey`, and for names in `via` | string (field name) | The field used as the login username (for password) or the matched value. | +| `via` | no | string, list of strings, or list of objects | The providers users sign in through (see below). Without it the entity signs no one in. | | `roles` | no | string (field name) | Names an `enum` field that holds the principal's role. See [roles overview](../roles/overview.md). | -| `bindings` | no | list of objects | External identity providers (OIDC/SAML). Each entry sets `provider`, `identifier`, optional `claim_mapping` and `auto_provision`. See [claim mapping](./claim-mapping.md). | -`via` accepts plain strings for the built-in `password` and `apiKey` methods. A list -entry may instead be an object that declares an external provider binding inline, -equivalent to adding the same entry under `auth.bindings`. Each object takes: +`via` accepts a provider name, a list of names, or a list of binding objects. A +name uses the entity's `auth.identifier`; `password` and `apiKey` are built in, and +any other name must be a key of `providers:` (`AUTH_VIA_UNKNOWN_PROVIDER`). A +binding object takes: | Key | Required | Type | Description | |-----|----------|------|-------------| -| `provider` | yes | string | Name of the configured external provider (OIDC/SAML). | -| `identifier` | no | string (field name) | Field matched against the provider's principal. Defaults to `auth.identifier`. | -| `claim_mapping` | no | map (string → string) | Maps provider claims onto entity fields. See [claim mapping](./claim-mapping.md). | +| `provider` | yes | string | `password`, `apiKey`, or the name of a provider declared under `providers:` (`BINDING_PROVIDER_NOT_FOUND`). | +| `identifier` | no | string (field name) | Field matched against the provider's principal. Defaults to `auth.identifier`, and must be a field of the entity (`BINDING_IDENTIFIER_FIELD_NOT_FOUND`). | +| `claim_mapping` | no | map (string to string) | Maps entity fields to provider claims. See [claim mapping](./claim-mapping.md). | | `auto_provision` | no | boolean | Create a local record on first login when no match exists. Defaults to `true`. | +| `relations` | no | map | Fills a foreign key from a record whose fields match claims. See [claim mapping](./claim-mapping.md). | -Bindings declared in `via` are merged with any under `auth.bindings`; a `via` binding -wins if both name the same `provider`. +A list holds either names or binding objects; name a built-in provider in a list of +objects as `- provider: password`. ```yaml entities: @@ -111,29 +111,39 @@ entities: auth: identifier: email via: - - password + - provider: password - provider: google identifier: email - claim_mapping: { email: email, name: displayName } + claim_mapping: { email: email, displayName: name } auto_provision: true roles: role ``` ### Role hierarchy -A role hierarchy can be declared in a top-level `roles:` map keyed by entity name: +A hierarchy is declared in the entity's managed `roles:` block. Each key lists the +roles it includes, transitively: ```yaml -roles: +entities: user: - admin: [moderator] - moderator: [user] + type: auth + auth: + identifier: email + via: password + roles: + values: [admin, moderator, user] + default: user + hierarchy: + admin: [moderator] + moderator: [user] + fields: + id: { type: id } + email: { type: string, required: true, unique: true } ``` -This is parsed and reaches the generated React UI's RBAC matrix, but it is NOT applied -during backend access enforcement: a principal with role `admin` does NOT inherit -`moderator`'s permissions. Only exact role matches are checked today. See -[roles hierarchy](../roles/hierarchy.md). +A principal with role `admin` then matches rules for `user:moderator` and +`user:user` too. See [roles hierarchy](../roles/hierarchy.md). ## How a request is authenticated @@ -141,7 +151,7 @@ The generated backend resolves a principal by trying credentials in this order: 1. **Bearer JWT** (`Authorization: Bearer `) -- used for password-based entities. The token carries only `sub` and `identity_collection`; the identity record and its - roles are loaded from MongoDB on every request, so role changes apply immediately. + roles are loaded from the datastore on every request, so role changes apply immediately. 2. **API key** (`X-API-Key: `, or `Authorization: Bearer `) -- matched against the top-level `apiKey` field of each `apiKey` entity. 3. **HTTP Basic** (`Authorization: Basic `) -- the identifier @@ -151,11 +161,11 @@ The generated backend resolves a principal by trying credentials in this order: The resolved principal exposes: - `principal.sub` -- the primary-key value of the matched record. - `principal.identity_collection` -- the entity/collection that matched. -- `principal.roles` -- read from the `auth.roles` enum field (falling back to a `roles` - or `role` field if `auth.roles` is unset). +- `principal.roles` -- read from the `auth.roles` enum field, or from the managed + roles under `_auth` when the entity declares a `roles:` block. - `principal.record` -- the matched record. Stored fields are referenced in `when` rules as `principal.record.`. In the generated FastAPI/DRF backends this is the raw - MongoDB document (including the internal `_auth` namespace); the `stackform run` dev + stored record (including the internal `_auth` namespace); the `stackform run` dev server passes a sanitized record. ## Internal Storage @@ -165,11 +175,11 @@ internal `_auth` namespace. The hash format is `pbkdf2$sha256$$$` (PBKDF2-HMAC-SHA256, default 390000 rounds, configurable via `PASSWORD_HASH_ROUNDS`). -Roles are NOT stored under `_auth` -- they live in the declared `enum` field and are read -from there at request time. +A role field lives in the record like any other field. Managed roles live under +`_auth.roles` and are read from there at request time. ``` -# MongoDB document (internal) +# Stored record (internal) { "id": "abc", "email": "alice@example.com", "role": "admin", "_auth": { "passwordHash": "pbkdf2$sha256$390000$..." } } ``` @@ -183,7 +193,7 @@ is the raw document; do not rely on access rules to hide `_auth` there. | Variable | Default | Effect | |----------|---------|--------| | `JWT_SECRET` | dev placeholder | HS256 signing secret for issued tokens. Set a strong value in production. | -| `JWT_EXPIRATION_HOURS` | `24` | Token lifetime in hours. | +| `JWT_EXPIRATION_HOURS` | `24` | Token lifetime in hours when `session.ttl` is unset; `session.ttl` sets it in seconds. | | `PASSWORD_HASH_ROUNDS` | `390000` | PBKDF2-SHA256 iteration count for password hashing. | ## Examples @@ -317,8 +327,8 @@ access: apiClient:enterprise - **Password is never stored.** Only `_auth.passwordHash` is persisted, using PBKDF2-SHA256 with 390000 iterations by default. -- **Entity without `auth.roles`.** The entity still authenticates principals but has no - roles. `access: public`, `access: "*"`, and `access: user:*` still work; +- **Entity without roles.** With neither `auth.roles` nor a `roles:` block, the + entity still authenticates principals but has no roles. `access: public`, `access: "*"`, and `access: user:*` still work; `access: user:admin` will never match because no role values exist. ## Errors @@ -328,6 +338,15 @@ access: apiClient:enterprise | `type: auth` without `auth:` block | schema validation | `auth` is required when type is "auth" | | `auth.roles` names a missing field | `AUTH_ROLES_FIELD_NOT_FOUND` | "Roles field 'X' not found in entity 'Y'" | | `auth.roles` field is not an enum | `AUTH_ROLES_FIELD_NOT_ENUM` | "Roles field 'X' must be of type 'enum'" | +| `password` or `apiKey` entity without `auth.identifier` or a binding `identifier` | schema validation | `'password' is not of type 'object'` | +| `auth.identifier` names a missing field | `AUTH_IDENTIFIER_FIELD_NOT_FOUND` | "Auth identifier 'X' not found in entity 'Y'" | +| `apiKey` entity without an `apiKey` field | `AUTH_API_KEY_FIELD_MISSING` | "Auth entity 'Y' uses via apiKey but declares no apiKey field" | +| `via` names an undeclared provider | `AUTH_VIA_UNKNOWN_PROVIDER` | "Auth entity 'Y' uses via 'X', which is not a declared provider" | +| Binding names an undeclared provider | `BINDING_PROVIDER_NOT_FOUND` | "Auth entity 'Y' binds undeclared provider 'X'" | +| Binding `identifier` names a missing field | `BINDING_IDENTIFIER_FIELD_NOT_FOUND` | "Binding identifier 'X' not found in entity 'Y'" | +| `roles.default` not in `roles.values` | `ROLES_DEFAULT_UNKNOWN_ROLE` | "Default role 'X' is not one of the declared roles" | +| `roles.hierarchy` names an undeclared role | `ROLES_HIERARCHY_UNKNOWN_ROLE` | "Role hierarchy references undeclared role 'X'" | +| `roles.hierarchy` is cyclic | `ROLES_HIERARCHY_CYCLE` | "Role hierarchy for 'Y' is cyclic through 'X'" | | `password` declared in `fields:` | `AUTH_PASSWORD_FIELD_FORBIDDEN` | "Password input is implicit when auth.via includes password" | | `passwordHash` declared in `fields:` | `AUTH_PASSWORD_HASH_FIELD_FORBIDDEN` | "passwordHash is implicit storage when auth.via includes password" | | Unscoped role with multiple auth entities | `ACCESS_AMBIGUOUS_ROLE` | "Unscoped role 'X' is ambiguous with multiple auth entities; use 'entity:X'" | diff --git a/docs/v0.2/Reference/auth/password-provider.md b/docs/v0.2/Reference/auth/password-provider.md index 2bd2e9a6..ca7ee455 100644 --- a/docs/v0.2/Reference/auth/password-provider.md +++ b/docs/v0.2/Reference/auth/password-provider.md @@ -53,16 +53,13 @@ The login/register endpoints in the rest of this page assume the `api:` block ab so the URLs are `/api/v1/...`. Drop `version` and they become `/api/...`; drop the whole `api:` block and `basePath` still defaults to `/api`. -> **Enforcement status** +> **Enforcement** > -> Roles (exact match), `:*` scoping, and ownership/ABAC `when` rules +> Roles, role hierarchies, `:*` scoping, and ownership/ABAC `when` rules > are enforced by the generated FastAPI backend, the generated DRF backend, and the -> `stackform run` dev server alike. A request that fails a rule gets 401 (missing or -> invalid credentials), 403 (authenticated but not allowed), or 404 (record not -> found, checked before ownership). Two current limitations: role hierarchy (for -> example `admin` inheriting `moderator`) is accepted in YAML but not yet applied -> during enforcement -- only exact role matches are checked; and conditional or -> ownership rules require a v0.2 spec, because v0.1 specs do not parse `when` rules. +> `stackform run` dev server alike. A request that no rule admits gets 401 when it +> carries no valid credentials and 403 otherwise; a missing record answers 404 +> before ownership is checked. ## Register and login are off by default @@ -87,10 +84,9 @@ The provider is configured under the entity's `auth:` block. | Field | Required | Type | Description | |-------|----------|------|-------------| -| `identifier` | effectively yes | string (field name) | The field used as the login username. Not schema-enforced, but login needs it. Common values: `email`, `username`. | -| `via` | effectively yes | string or list | Built-in providers to enable. Use `password` here; may be a list (for example `[password, apiKey]`) to combine built-in providers. A list entry may also be an object declaring an inline provider binding (`provider`, `identifier`, `claim_mapping`, `auto_provision`) -- equivalent to an `auth.bindings` entry. See [Auth overview](./overview.md#auth-block). | +| `identifier` | yes, with `via: password` | string (field name) | The field used as the login username (schema validation error when missing, unless the `via` binding sets `identifier`). Common values: `email`, `username`. | +| `via` | yes, for password login | string or list | Providers to enable. Use `password` here; a list (for example `[password, apiKey]`) combines providers. A list entry may also be a binding object (`provider`, `identifier`, `claim_mapping`, `auto_provision`, `relations`) that attaches an OIDC or SAML provider. See [Auth overview](./overview.md#auth-block) and [Claim mapping](./claim-mapping.md). | | `roles` | no | string (field name) | Names an existing `enum` field that carries the principal's role. See [Roles overview](../roles/overview.md). | -| `bindings` | no | list | External identity providers (OIDC/SAML). See [Login, SSO, and the token flow](./login-and-sso.md) and [Claim mapping](./claim-mapping.md). | The `identifier` field should declare `unique: true` so two accounts cannot share a login value. The datastore enforces that declaration with a SQL unique constraint diff --git a/docs/v0.2/Reference/configuration/api.md b/docs/v0.2/Reference/configuration/api.md index 39124cba..3cc1e226 100644 --- a/docs/v0.2/Reference/configuration/api.md +++ b/docs/v0.2/Reference/configuration/api.md @@ -1,9 +1,9 @@ --- title: "`api:`" -description: The `api:` section sets URL prefixes (basePath/version), the default port, CORS, and which CRUD and auth operations are generated. +description: The `api:` section sets URL prefixes (basePath/version), the default port, CORS, security headers, and which CRUD and auth operations are generated. --- -The `api:` section controls global API settings: the URL prefix (`basePath` + `version`), the default port, CORS, and which CRUD and auth operations the generator produces. +The `api:` section controls global API settings: the URL prefix (`basePath` + `version`), the default port, CORS, standard security headers, and which CRUD and auth operations the generator produces. ## Schema @@ -17,6 +17,7 @@ api: methods: ["*"] headers: ["*"] credentials: false + securityHeaders: true # optional, default: true operations: defaults: # optional; use this to DISABLE ops (all six are on by default) replace: false @@ -56,9 +57,7 @@ The same prefix is used to derive the frontend's API base URL (`basePath` plus t Type: integer (1-65535). Default: `8000`. -The port baked into the generated FastAPI app. It can be overridden at runtime with the `PORT` environment variable. This field is read only for `version: "0.2"` specs. - -For the `stackform run` dev server the port comes from the `-p`/`--port` flag (default 8000), not from this field. +The port the API listens on. `stackform run` binds to it unless `-p`/`--port` is given; every generated backend (FastAPI, Django REST Framework, raw ASGI) uses it as its `PORT` setting, which the `PORT` environment variable overrides at runtime; and `deploy.port` defaults to it. ### `cors` @@ -73,6 +72,22 @@ Configures Cross-Origin Resource Sharing (CORS). When the `cors:` block is omitt | `headers` | list of strings | `["*"]` | Allowed HTTP headers. | | `credentials` | boolean | `false` | Allow credentials (cookies, authorization headers). | +### `securityHeaders` + +Type: boolean. Default: `true`. + +Adds standard security headers to every response of the dev server and of every generated backend: + +| Header | Value | +|--------|-------| +| `X-Content-Type-Options` | `nosniff` | +| `X-Frame-Options` | `DENY` | +| `Referrer-Policy` | `no-referrer` | +| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains` | +| `Cross-Origin-Opener-Policy` | `same-origin` | + +A header the response already carries is left as it is. Set `securityHeaders: false` when a reverse proxy in front of the API sets these headers. + ### `operations.defaults` Type: object (operation flags). Optional. @@ -103,7 +118,7 @@ A repository can further narrow its own operations with its `crud:` list, and a Type: object (operation flags). Optional. -Controls the `register` and `login` endpoints for `type: auth` entities. These are **not generated unless you list them here.** Omitting `operations.auth` produces an auth entity with no register or login route. +Controls the `register` and `login` endpoints for `type: auth` entities. These are **not generated unless you list them here.** Omitting `operations.auth` produces an auth entity with no register or login route. A controller that declares a `login` route still serves login for its own identity. | Field | Type | Default | |-------|------|---------| @@ -131,12 +146,12 @@ A disabled operation is removed end to end: no repository method, no service, no ## Environment-variable interpolation -Any string value in the spec, including those under `api:`, may reference an environment variable with `${VAR}` or `${VAR:-default}`. Interpolation runs after the YAML is loaded and before schema validation, so the substituted value must still satisfy the schema. A missing variable with no default aborts generation with `Environment variable 'VAR' is required`. +Any string value in the spec, including those under `api:`, may reference an environment variable with `${VAR}` or `${VAR:-default}`. Interpolation runs after the YAML is loaded and before schema validation, so the substituted value must still satisfy the schema. The result is always a string, so interpolate only string-typed keys: `port: ${PORT}` fails with `SCHEMA_VALIDATION_ERROR`; set the runtime port through the `PORT` environment variable of the generated app instead. A missing variable with no default aborts generation with `Environment variable 'VAR' is required`. ```yaml api: basePath: ${API_BASE_PATH:-/api} - port: ${PORT:-8000} + version: ${API_VERSION:-v1} ``` ## Example diff --git a/docs/v0.2/Reference/configuration/datastores.md b/docs/v0.2/Reference/configuration/datastores.md index c1f1d10c..9b27ebe4 100644 --- a/docs/v0.2/Reference/configuration/datastores.md +++ b/docs/v0.2/Reference/configuration/datastores.md @@ -7,6 +7,8 @@ The `datastores:` section registers one or more databases by name. Each reposito A spec that only uses the `mongo:` block (see [`mongo:`](./mongo.md)) is the single-datastore shortcut: it behaves as one default datastore. Use `datastores:` when you need more than one, or a relational database. +A spec cannot declare both `mongo:` and a record datastore (`mongodb` or `postgres`) under `datastores:`: validation rejects it with "mongo and a record datastore are mutually exclusive: declare MongoDB under datastores instead". File datastores (`filesystem`, `s3`) can sit next to `mongo:`. + ## Schema ```yaml @@ -95,7 +97,7 @@ repositories: recent: query: sql: "SELECT * FROM events WHERE user_id = :uid ORDER BY at DESC LIMIT 50" - params: { uid: "$principal.id" } + params: { uid: "$principal.sub" } ``` Bindings (`$principal.*`, route params, `$body.*`) are resolved at request time and passed as bound parameters. Mixing the forms (a SQL query on a MongoDB datastore, or a Mongo command on a relational one) is a validation error (`QUERY_FORM_MISMATCH`). diff --git a/docs/v0.2/Reference/configuration/deploy.md b/docs/v0.2/Reference/configuration/deploy.md new file mode 100644 index 00000000..68cdeb09 --- /dev/null +++ b/docs/v0.2/Reference/configuration/deploy.md @@ -0,0 +1,99 @@ +--- +title: "`deploy:`" +description: The `deploy:` section makes `stackform build` emit Docker Compose or systemd deployment artifacts, with the app's datastores and any extra services. +--- + +The `deploy:` section makes `stackform build` (with `all` or `deploy`) write deployment artifacts next to the backend: an Ansible inventory and playbook plus either a Docker Compose stack or a systemd unit. Without it, no deploy artifacts are produced. + +## Schema + +```yaml +deploy: + runtime: docker # required: docker | systemd + name: my-app # optional, default: app + port: 8000 # optional, default: api.port + mongo: + local: true # optional, default: derived from each MongoDB URI + docker: + image: ghcr.io/me/my-app:1.0 # optional; default: build ./backend + systemd: + user: ubuntu # optional, default: ubuntu + restart: on-failure # optional, default: on-failure + include: # optional, docker only: Compose files to include + - monitoring.compose.yml + services: # optional, docker only: extra Compose services + worker: + build: ./worker + environment: { QUEUE: jobs } + depends_on: [app] +``` + +## Fields + +### `runtime` + +Type: `docker` or `systemd`. Required. + +- `docker`: a `docker-compose.yml` with the `app` service, one service per local datastore, a `frontend` service when the spec has a `ui:` block, and the extra `services`; plus `backend/Dockerfile`, `.env.example` and Ansible roles that install Docker and start the stack. +- `systemd`: an `app.service` unit that runs the backend with uvicorn from `/opt/`, plus Ansible roles that install Python and, for a local MongoDB datastore, MongoDB. + +### `name` + +Type: string. Default: `app`. + +Names the deployment: the systemd unit description and its install directory `/opt/`, and the Ansible play. + +### `port` + +Type: integer, 1 to 65535. Default: [`api.port`](./api.md#port). + +The port the deployed app listens on. Docker publishes it as `${APP_PORT:-}` and passes it to the app as `PORT`. + +### `mongo.local` + +Type: boolean. Optional. + +Whether the MongoDB datastores run on the deploy host. `true` adds a MongoDB container (Docker) or installs MongoDB (systemd); `false` points the app at the URI from the spec. When unset, a MongoDB datastore is local exactly when its URI names `localhost` or `127.0.0.1`. A PostgreSQL datastore is local by the same rule applied to its DSN. + +### `docker.image` + +Type: string. Optional. + +A prebuilt image for the `app` service. When set, the stack pulls it; otherwise it builds `backend/Dockerfile`. + +### `systemd.user` and `systemd.restart` + +Type: string. Defaults: `ubuntu` and `on-failure`. + +The `User=` and `Restart=` values of the generated unit. + +### `include` + +Type: list of strings. Optional; used by `runtime: docker`. + +Paths copied into the top-level `include:` of the generated `docker-compose.yml`. + +### `services` + +Type: map of service name to service. Optional; used by `runtime: docker`. + +Extra Compose services added to the stack. Each needs `image` or `build`: + +| Key | Type | Description | +|-----|------|-------------| +| `image` | string | Image to run. | +| `build` | string or `{ context, dockerfile, args }` | Build context relative to the spec. It must be a subdirectory of the spec's directory; it is copied into the output so the bundle is self-contained. | +| `ports` | list of integers or strings | Published ports. | +| `environment` | map of strings, or list of `KEY=value` strings | Environment variables. | +| `volumes` | list of strings | Volume mounts. | +| `depends_on` | list of service names, or map of name to `{ condition }` | Start order; `condition` is `service_started`, `service_healthy` or `service_completed_successfully`. | +| `command` | string or list of strings | Command override. | + +## Errors + +| Code / message | Cause | +|----------------|-------| +| `SCHEMA_VALIDATION_ERROR` | `runtime` missing or unknown, an unknown key, a service with neither `image` nor `build`, or a value of the wrong type. | +| `deploy service '' build context not found` | A `build` context directory does not exist. | +| `deploy service '' build context must be a subdirectory of the spec that excludes the output` | A `build` context contains the spec or the output directory. | +| `No 'deploy' block found in the YAML spec.` | `stackform build deploy` on a spec without `deploy:`. | diff --git a/docs/v0.2/Reference/configuration/environment-variables.md b/docs/v0.2/Reference/configuration/environment-variables.md index 9e04f16a..e3833e9f 100644 --- a/docs/v0.2/Reference/configuration/environment-variables.md +++ b/docs/v0.2/Reference/configuration/environment-variables.md @@ -29,8 +29,7 @@ not overlap: > `settings._CLIENT_SECRET.get_secret_value()`. The literal secret must > never live in the spec: write only a placeholder there (or omit the field) and > provide the real value through the runtime environment. The same applies to -> `JWT_SECRET`. Earlier prerelease builds baked `client_secret` into the output as a -> literal; that no longer happens. +> `JWT_SECRET`. ## Spec Interpolation @@ -120,7 +119,11 @@ not via spec interpolation. They are grouped by who reads them. | `MONGO_DB` | `yaml_api` | MongoDB database name. | | `JWT_SECRET` | *(generated)* | HMAC key for JWT signing (HS256), a `SecretStr` setting. The generated backend bundle ships a `.env` with a real value (`secrets.token_urlsafe`), so a fresh bundle runs without manual setup; override it for production. | | `_CLIENT_SECRET` | *(none)* | OAuth2 client secret for each confidential OIDC provider (e.g. `KEYCLOAK_CLIENT_SECRET` for a provider named `keycloak`), a `SecretStr` setting read at run time via `settings._CLIENT_SECRET.get_secret_value()`. Emitted only when the provider declares a `client_secret`. | -| `JWT_EXPIRATION_HOURS` | `24` | JWT token lifetime in hours. | +| `JWT_EXPIRATION_HOURS` | `24` | JWT token lifetime in hours, when [`session.ttl`](./session.md#ttl) is unset and refresh is off. | +| `JWT_ACCESS_TTL_SECONDS` | `session.ttl` (or `900` with refresh) | Access-token lifetime in seconds; present when `session.ttl` is set or `session.refresh` is on. | +| `JWT_REFRESH_TTL_SECONDS` | `2592000` | Refresh-token lifetime in seconds; present when `session.refresh` is on. | +| `JWT_PRIVATE_KEY` | *(generated)* | PEM RSA private key that replaces `JWT_SECRET` when `session.algorithm` is `RS256`. | +| `JWT_RETIRED_PUBLIC_KEY` | `""` | PEM RSA public key of a previous signing key whose tokens stay valid during rotation (`RS256`). | | `PASSWORD_HASH_ROUNDS` | `390000` | PBKDF2-HMAC-SHA256 iteration count for password hashing. | | `FRONTEND_URL` | `""` | Base URL the OIDC callback redirects to (`{FRONTEND_URL}/auth/callback?code=...`). Falls back to the request base URL when unset. | | `HOST` | `127.0.0.1` | Host the generated `app.py` binds to when run directly. | @@ -191,7 +194,7 @@ template task then renders the remote `/opt//.env` (mode `0600`) from thos values, and `docker compose` reads it via `env_file`. The `.secrets/` directory and the remote `.env` are the only places the plaintext secret ever exists. -This currently covers the framework secrets the generated code reads (`JWT_SECRET` +This covers the framework secrets the generated code reads (`JWT_SECRET` and each confidential provider's `_CLIENT_SECRET`). Any additional runtime variables your backend needs are declared explicitly in the spec's `deploy.env_vars` list and emitted with an empty default for you to fill. diff --git a/docs/v0.2/Reference/configuration/meta.json b/docs/v0.2/Reference/configuration/meta.json index 53b3a3f4..f90ac98a 100644 --- a/docs/v0.2/Reference/configuration/meta.json +++ b/docs/v0.2/Reference/configuration/meta.json @@ -4,6 +4,8 @@ "api", "datastores", "mongo", - "environment-variables" + "environment-variables", + "session", + "deploy" ] } diff --git a/docs/v0.2/Reference/configuration/mongo.md b/docs/v0.2/Reference/configuration/mongo.md index 77db795c..ad2a53f9 100644 --- a/docs/v0.2/Reference/configuration/mongo.md +++ b/docs/v0.2/Reference/configuration/mongo.md @@ -8,6 +8,8 @@ generated app. The whole block is optional; when omitted, the defaults below app This is the single-datastore shortcut: a spec with `mongo:` behaves as one default MongoDB datastore. To use more than one database, or a relational database, use [`datastores:`](./datastores.md) instead. +A spec cannot declare both `mongo:` and a record datastore (`mongodb` or `postgres`) under `datastores:`: validation rejects it with "mongo and a record datastore are mutually exclusive: declare MongoDB under datastores instead". File datastores (`filesystem`, `s3`) can sit next to `mongo:`. + ## Schema ```yaml diff --git a/docs/v0.2/Reference/configuration/session.md b/docs/v0.2/Reference/configuration/session.md new file mode 100644 index 00000000..f135e997 --- /dev/null +++ b/docs/v0.2/Reference/configuration/session.md @@ -0,0 +1,65 @@ +--- +title: "`session:`" +description: The `session:` section sets how the app signs the tokens it mints at login, how long they live, whether they rotate through refresh tokens, and whether they travel in a cookie. +--- + +The `session:` section configures the session tokens the app mints when an identity logs in (password login, and OIDC or SAML login with `trust: app`). Every key is optional; omitting the section keeps HS256 bearer tokens that live 24 hours. + +## Schema + +```yaml +session: + algorithm: HS256 # HS256 (default) | RS256 + ttl: 900 # access-token lifetime in seconds + refresh: true # default: false + delivery: token-url # token-url (default) | cookie +``` + +## Fields + +### `algorithm` + +Type: `HS256` or `RS256`. Default: `HS256`. + +- `HS256` signs with the shared secret `JWT_SECRET`. +- `RS256` signs with the RSA private key in `JWT_PRIVATE_KEY` (PEM) and serves the public key set at `GET /.well-known/jwks.json`, outside `basePath`. Each token carries a `kid` matching its key. To rotate keys without ending live sessions, put the old public key in `JWT_RETIRED_PUBLIC_KEY` while tokens signed by it are still valid; the JWKS then publishes both keys. + +`stackform build` rejects `RS256` on a spec that never mints a session token (no password identity and no `trust: app` OIDC or SAML login) with `SESSION_ALGORITHM_UNUSED`. + +### `ttl` + +Type: integer, at least 1. Optional. + +Access-token lifetime in seconds, read from the `JWT_ACCESS_TTL_SECONDS` setting it seeds. Without `ttl`, tokens live `JWT_EXPIRATION_HOURS` hours (default 24), or 900 seconds when `refresh` is on. + +### `refresh` + +Type: boolean. Default: `false`. + +Login also returns a `refresh_token`, and every password identity gets `POST /{identity}/refresh`, which takes `{ "refresh_token": "..." }` and returns a new `{ "token", "refresh_token" }` pair. Each refresh token is single-use: presenting one that was already used revokes its whole session family and answers `401 Invalid refresh token`. Refresh tokens live `JWT_REFRESH_TTL_SECONDS` seconds (default 2592000, 30 days). + +### `delivery` + +Type: `token-url` or `cookie`. Default: `token-url`. + +- `token-url`: login returns the token in the response body; clients send it as `Authorization: Bearer `. +- `cookie`: login sets an `HttpOnly; SameSite=Lax` cookie named `session` (`Secure` over HTTPS) and a readable `csrf_token` cookie, and the login body carries the user record and `csrf_token` instead of the token. Unsafe requests (`POST`, `PUT`, `PATCH`, `DELETE`) that carry a session cookie must send the `csrf_token` value in the `x-csrf-token` header, otherwise they are rejected with `403 CSRF token missing or invalid`. With `refresh` on, the refresh token travels in an `HttpOnly` `refresh_token` cookie. `POST /auth/logout`, below `basePath`, expires the cookies. Cookie delivery assumes the UI and the API share an origin. + +## Backend support + +| Feature | `stackform run` | FastAPI | DRF | raw ASGI | +|---------|-----------------|---------|-----|----------| +| `algorithm` | yes | yes | yes | yes | +| `ttl` | yes | yes | yes | yes | +| `refresh` | yes | yes | no | no | +| `delivery: cookie` | yes | yes | no | no | + +Building a DRF or raw backend from a spec that sets `refresh: true` or `delivery: cookie` fails with `BACKEND_FEATURE_UNSUPPORTED`. + +## Errors + +| Code | Cause | +|------|-------| +| `SCHEMA_VALIDATION_ERROR` | An unknown key, an `algorithm` or `delivery` outside the listed values, or a `ttl` below 1. | +| `SESSION_ALGORITHM_UNUSED` | `algorithm: RS256` on a spec that never mints a session token (`stackform build`). | +| `BACKEND_FEATURE_UNSUPPORTED` | `refresh` or `delivery: cookie` on the DRF or raw backend. | diff --git a/docs/v0.2/Reference/controllers/access-control.md b/docs/v0.2/Reference/controllers/access-control.md index bfadedb5..7bc62700 100644 --- a/docs/v0.2/Reference/controllers/access-control.md +++ b/docs/v0.2/Reference/controllers/access-control.md @@ -5,16 +5,13 @@ description: Determines who is allowed to call each route, declared on controlle `access:` determines who is allowed to call each route. You declare it on a controller (the default for every operation it exposes) and you can override it per operation. -> **Enforcement status** +> **Enforcement** > -> Roles (exact match), `:*` scoping, and ownership/ABAC `when` rules +> Roles, role hierarchies, `:*` scoping, and ownership/ABAC `when` rules > are enforced by the generated FastAPI backend, the generated DRF backend, and the -> `stackform run` dev server alike. A request that fails a rule gets 401 (missing or -> invalid credentials), 403 (authenticated but not allowed), or 404 (record not -> found, checked before ownership). Two current limitations: role hierarchy (for -> example `admin` inheriting `moderator`) is accepted in YAML but not yet applied -> during enforcement -- only exact role matches are checked; and conditional or -> ownership rules require a v0.2 spec, because v0.1 specs do not parse `when` rules. +> `stackform run` dev server alike. A request that no rule admits gets 401 when it +> carries no valid credentials and 403 otherwise; a missing record answers 404 +> before ownership is checked. The URLs shown on this page assume this `api:` block: @@ -95,6 +92,7 @@ access: | `given.` | yes, for every root in `when:` | `identity:` or `[ by ]` | Binds a symbol used in `when:`. The symbol name is yours to choose. Any root referenced in `when:` but not declared here aborts generation with `WHEN_UNBOUND_SYMBOL`. | | `when` | no | string or list of strings | Condition expression. List entries are ANDed. See [when expressions](../when-expressions/overview.md). | | `then` | no | `"allow"` (default) or `"deny"` | Effect when the rule matches. | +| `reason` | no | string | Detail of the 403 response this rule produces; see Evaluation Order. | Symbol names in `given` are arbitrary. There are no built-in roots: every symbol a `when:` references must be declared in `given`, or generation aborts with @@ -111,19 +109,20 @@ given: A ` by ` document binding loads a record by a different path parameter (for example `given: { owner: myOtherCollection by myRefId }`). -### Parsed but not enforced - -`filter`, `writeFields`, and `reason` are accepted on a rule object and validated, -but they do not affect enforcement in the generated backend. Use `then: deny` for -explicit denials; do not rely on `filter`/`writeFields` to scope reads or writes. +On a route that runs a custom repository operation or a handler, a bare document +binding loads the record named by the route's `{id}` parameter, within the scope +of the other path parameters that name fields of its collection. The rule decides +on that record before the operation runs; the operation's result is not +row-filtered. A route without `{id}` cannot bind its record and is reported as +`DOCUMENT_BINDING_INVALID`. ## Evaluation Order 1. If the route is `public`, it is served with no checks. 2. Otherwise a principal must be present, or the request is rejected with 401. 3. Rules in the `access:` list are evaluated top-to-bottom. -4. The first rule that matches the principal and whose `when:` evaluates true wins. `then: allow` (the default) serves the request; `then: deny` returns 403. -5. If no rule matches, the request is denied with 403. +4. The first rule that matches the principal and whose `when:` evaluates true wins. `then: allow` (the default) serves the request; `then: deny` returns 403 with the rule's `reason` as detail. +5. If no rule matches, the request is denied with 403, whose detail is the `reason` of the first allow rule that has one, or `Forbidden`. ### Document rules and short-circuiting @@ -171,12 +170,12 @@ controllers: - given: principal: identity:user member: request.body:createMembership - when: member.body.role not equals 'admin' + when: member.role not equals 'admin' ``` Admins fall through the first rule. Everyone else may create a membership only when the submitted `role` is not `admin`. The `:createMembership` DTO validates -the body before the rule runs; an invalid body is 422 and the rule never fires. +the body; an admitted request with an invalid body gets 422. ### Applicability matrix @@ -194,9 +193,9 @@ A body binding (`request.body` or `request:`) on a bodiless operation ### Runtime model Request resolution is fail-safe: a missing leaf resolves to `null`, never an -error. A typed body is validated **before** authorization — an invalid body -returns 422 and the rule never executes. This runs identically in the generated -FastAPI backend, the generated DRF backend, and `stackform run`. +error. A typed body is validated against its DTO: a request the rules admit +whose body does not match returns 422. Request bindings resolve the same way in the +generated FastAPI backend, the generated DRF backend, and `stackform run`. ```mermaid flowchart TD @@ -370,6 +369,7 @@ controllers: | Unscoped role with multiple auth entities | `ACCESS_AMBIGUOUS_ROLE` | "Unscoped role 'X' is ambiguous with multiple auth entities; use 'entity:X'" | | Role used with no auth entities defined | `ACCESS_NO_AUTH_ENTITY` | "Cannot use role 'X' without auth entities" | | Malformed `given` document binding | `DOCUMENT_BINDING_INVALID` | "Invalid document binding: 'X'. Expected '' or ' by '" | +| Bare document binding on a custom route without `{id}` | `DOCUMENT_BINDING_INVALID` | "'X' binds the route's record, but the route path has no record parameter" | | Invalid `when` expression | `WHEN_EXPR_INVALID` / `WHEN_OPERAND_INVALID` / `WHEN_OPERAND_MISSING` | See [when expressions](../when-expressions/overview.md). | | `when` references a root not declared in `given` | `WHEN_UNBOUND_SYMBOL` | "Unbound symbol 'X' in when expression; declare it in 'given'" | | Invalid `request*` binding value in `given` | `REQUEST_BINDING_INVALID` | "Invalid request binding: 'X'" | diff --git a/docs/v0.2/Reference/controllers/operations.md b/docs/v0.2/Reference/controllers/operations.md index 4eedd8b7..c1195175 100644 --- a/docs/v0.2/Reference/controllers/operations.md +++ b/docs/v0.2/Reference/controllers/operations.md @@ -3,11 +3,14 @@ title: "Controller Operations" description: Each key under controllers..operations configures or overrides one HTTP route. --- -Each key under `controllers..operations` overrides one HTTP route on a -minified controller. The set of routes a controller exposes is decided by the -service group it points at (see "Which operations exist" below); the -`operations:` map only customizes method, path, and access for those routes, or -disables them with `: false`. +Each key under `controllers..operations` overrides one HTTP route of the +controller. On a minified controller the routes are those of the service group +it points at (see "Which operations exist" below); on an +[explicit routes](overview.md#explicit-routes-form) controller they are the +entries of its `routes:` map. The `operations:` map only customizes method, +path, and access for those routes, or drops them with `: false`. Naming a +route the controller does not expose is reported as +`DESUGAR_OPERATION_NOT_EXPOSED`. ## Operation config fields @@ -147,9 +150,8 @@ silently makes every operation public. > **Watch out** > -> Routes are only actually protected once at least one `type: auth` entity -> exists in the spec. With no auth entity in the spec, even non-`public` routes -> stay open. +> A non-`public` route needs a `type: auth` entity to authenticate against. +> With no auth entity in the spec, such a route answers every request with 401. ## Auth operations are off by default @@ -237,13 +239,10 @@ Notes on the rule object: `REQUEST_PATH_PARAM_NOT_DECLARED`, `REQUEST_TYPE_NOT_FOUND`, `REQUEST_BINDING_INVALID`). -> **Enforcement status** +> **Enforcement** > -> Roles (exact match), `:*` scoping, and ownership/ABAC `when` rules +> Roles, role hierarchies, `:*` scoping, and ownership/ABAC `when` rules > are enforced by the generated FastAPI backend, the generated DRF backend, and the -> `stackform run` dev server alike. A request that fails a rule gets 401 (missing or -> invalid credentials), 403 (authenticated but not allowed), or 404 (record not -> found, checked before ownership). Two current limitations: role hierarchy (for -> example `admin` inheriting `moderator`) is accepted in YAML but not yet applied -> during enforcement -- only exact role matches are checked; and conditional or -> ownership rules require a v0.2 spec, because v0.1 specs do not parse `when` rules. +> `stackform run` dev server alike. A request that no rule admits gets 401 when it +> carries no valid credentials and 403 otherwise; a missing record answers 404 +> before ownership is checked. diff --git a/docs/v0.2/Reference/controllers/overview.md b/docs/v0.2/Reference/controllers/overview.md index fde6cd3a..86bab4e7 100644 --- a/docs/v0.2/Reference/controllers/overview.md +++ b/docs/v0.2/Reference/controllers/overview.md @@ -50,18 +50,15 @@ With `api: { basePath: /api, version: v1 }` and `base_path: /my-collection`, the Type: access value (see [access-control](./access-control.md)). Optional. Default: `public`. -Default access rule applied to every operation. Individual operations override it with their own `access`. If you omit `access` on a minified controller, every operation defaults to `public`, so an entity is only protected once you set a non-public value. Protection is wired only when an identity entity (a `type: auth` entity) exists; with no auth entity in the spec, even non-public routes stay open. +Default access rule applied to every operation. Individual operations override it with their own `access`. If you omit `access` on a minified controller, every operation defaults to `public`, so an entity is only protected once you set a non-public value. A non-public route needs a `type: auth` entity to authenticate against; without one it answers every request with 401. -> **Enforcement status** +> **Enforcement** > -> Roles (exact match), `:*` scoping, and ownership/ABAC `when` rules +> Roles, role hierarchies, `:*` scoping, and ownership/ABAC `when` rules > are enforced by the generated FastAPI backend, the generated DRF backend, and the -> `stackform run` dev server alike. A request that fails a rule gets 401 (missing or -> invalid credentials), 403 (authenticated but not allowed), or 404 (record not -> found, checked before ownership). Two current limitations: role hierarchy (for -> example `admin` inheriting `moderator`) is accepted in YAML but not yet applied -> during enforcement -- only exact role matches are checked; and conditional or -> ownership rules require a v0.2 spec, because v0.1 specs do not parse `when` rules. +> `stackform run` dev server alike. A request that no rule admits gets 401 when it +> carries no valid credentials and 403 otherwise; a missing record answers 404 +> before ownership is checked. ### `operations` @@ -108,6 +105,8 @@ controllers: delete: false # no DELETE route is generated ``` +A request using a method that the path does not serve answers `405 Method Not Allowed` with an `Allow` header listing the methods it does serve; a path that serves no method answers 404. + Override the method, path, or access of an operation by giving it a map: ```yaml @@ -127,7 +126,7 @@ The `access` value selects who may call an operation: - `public` - no authentication required. - `"*"` - any authenticated principal. - `:*` - any principal from that auth entity. -- `:` - a principal from that entity holding the role (exact match). +- `:` - a principal from that entity holding the role, directly or through the role hierarchy. - `` - shorthand for the single auth entity in the spec; ambiguous (ACCESS_AMBIGUOUS_ROLE) when more than one auth entity exists, and an error (ACCESS_NO_AUTH_ENTITY) when there is none. - a list of rules, including ABAC `given`/`when` rules. @@ -180,6 +179,35 @@ controllers: The minified `service` form is sugar that expands to this `routes` form during generation. +The controller-level `access:` is the default of every explicit route that sets +none; without it the routes are public. `operations.` overrides the +`method`, `path` or `access` of the route ``, and `operations.: false` +drops it: + +```yaml +controllers: + posts: + base_path: /posts + access: "*" + routes: + list: { service: list-posts, method: GET, path: "" } + get: { service: get-posts, method: GET, path: "/{id}" } + delete: { service: delete-posts, method: DELETE, path: "/{id}" } + operations: + list: { path: /all, access: public } + delete: false +``` + +Here `GET /posts/all` is public, `GET /posts/{id}` needs any authenticated +principal, and there is no delete route. + +Each route is built from layers, each overriding the one before: + +| Controller form | Layers, least to most specific | +|-----------------|--------------------------------| +| Explicit routes (no `service:`) | controller `access` → `routes.` → `operations.` | +| Minified (`service:`) | controller `access` → the service operation and its default method and path → `operations.` → `routes.` | + ## Example ```yaml diff --git a/docs/v0.2/Reference/dto/overview.md b/docs/v0.2/Reference/dto/overview.md index df4367c6..51d4123f 100644 --- a/docs/v0.2/Reference/dto/overview.md +++ b/docs/v0.2/Reference/dto/overview.md @@ -46,7 +46,7 @@ difference is what a DTO is NOT allowed to declare. | `array` | yes | | `object` | yes | | `ref` | yes (target must be a declared entity or DTO) | -| `id` | NO — rejected with `DTO_FORBIDS_PERSISTENCE_ATTR` | +| `id` | NO — rejected with `SCHEMA_VALIDATION_ERROR` | A DTO describes request input, not a stored record, so it has no primary key. `type: id` is rejected. @@ -54,22 +54,31 @@ A DTO describes request input, not a stored record, so it has no primary key. ## Forbidden persistence attributes The following attributes are persistence concerns and are rejected anywhere in a -DTO field, failing generation with `DTO_FORBIDS_PERSISTENCE_ATTR`: +DTO field, failing generation with `SCHEMA_VALIDATION_ERROR`: | Attribute | Why it is forbidden | |-----------|---------------------| | `type: id` | A DTO is request input, not a stored record; it has no primary key. | | `unique` | Uniqueness is a storage/index concern; a DTO is never persisted. | -| `format` | The persistence/OpenAPI metadata hint has no meaning on a transient request shape. | -Lifecycle attributes that describe the field's behavior rather than storage are -allowed everywhere in a DTO: `default` and `readonly` are accepted. +Attributes that describe the value rather than its storage are allowed everywhere +in a DTO: `format`, `default`, and `readonly` are accepted, and `format` is +validated like on an entity field. A body that carries a key the DTO does not +declare returns 422. ## Validation timing When a request binding names a DTO (for example `given: { body: request:createMembership }`), -the request body is validated against that DTO **before** authorization runs. An -invalid body returns 422 and the access rule never executes. See +the request body is validated against that DTO on every route the binding guards: +CRUD routes, handler services and custom operations. A request the access rules +admit whose body does not match returns 422. Absent DTO fields that declare a +`default` take it, so a handler or operation receives the defaulted body. On an +update route every DTO field is optional. + +On a create, update or replace route the body is then written to the entity, so +it must also be a valid write of that entity: a DTO field the entity does not +declare, a readonly entity field, or a missing required entity field returns 422. +See [when expressions](../when-expressions/overview.md) for the binding forms and the runtime model. @@ -115,8 +124,7 @@ target is neither fails generation with `DTO_NOT_FOUND`. - **No id field.** Unlike entities, a DTO must not declare `type: id` and is not required to. It is never persisted, so it has no primary key. -- **`format`/`unique` are not silently ignored.** On entities these are - metadata-only and accepted; inside a DTO they are an error, not a no-op. +- **`unique` is not silently ignored.** Inside a DTO it is an error, not a no-op. - **`ref` target resolution.** A DTO `ref` is resolved against the union of declared entity names and declared DTO names. An unknown target is @@ -129,8 +137,8 @@ target is neither fails generation with `DTO_NOT_FOUND`. | Condition | Error code | Message | |-----------|-----------|---------| -| `type: id`, `unique`, or `format` on a DTO field | `DTO_FORBIDS_PERSISTENCE_ATTR` | "DTO field may not declare persistence attribute ''" | -| A DTO `ref` whose target is neither a declared entity nor DTO | `DTO_NOT_FOUND` | "DTO '' references unknown type ''" | +| `type: id` or `unique` on a DTO field | `SCHEMA_VALIDATION_ERROR` | The schema violation, for example "Unevaluated properties are not allowed ('unique' was unexpected)" | +| A DTO `ref` whose target is neither a declared entity nor DTO | `DTO_NOT_FOUND` | "DTO field references undeclared type ''" | | A request binding `:` naming a missing DTO/entity/primitive | `REQUEST_TYPE_NOT_FOUND` | "Request binding type '' is not a primitive, entity, or DTO" | ## See Also diff --git a/docs/v0.2/Reference/entities/field-types.md b/docs/v0.2/Reference/entities/field-types.md index 044de681..781c06e9 100644 --- a/docs/v0.2/Reference/entities/field-types.md +++ b/docs/v0.2/Reference/entities/field-types.md @@ -1,6 +1,6 @@ --- title: "Field Types" -description: Declares the type and constraints for each field in an entity's `fields:` map, and which constraints the generated app enforces. +description: Declares the type and constraints for each field in an entity's `fields:` map, and how the generated app checks them. --- Declares the type and constraints for each field in an entity's `fields:` map. @@ -16,19 +16,22 @@ fields: required: readonly: default: + unique: ``` Field names must start with a letter and contain only letters, numbers, or underscores; anything else fails with `INVALID_FIELD_NAME`. -## Enforced vs metadata-only +## Enforcement -Not every option you can declare is checked at runtime. Keep this distinction in mind: +The generated FastAPI, DRF and raw backends and the `stackform run` dev server check a +request body the same way: -- **Enforced on write** (HTTP 422 on violation): `type`, `required`, `min`, `max`, `minLength`, `maxLength`, enum membership, and ISO-8601 parsing for `datetime`. -- **Datastore-enforced** (`409 Conflict` on collision): `unique` creates a SQL - unique constraint or MongoDB unique partial index. -- **Metadata only** (declared but NOT enforced): `format` (email/uri/e164) is a - documentation/OpenAPI hint and does not reject a request. +- **On write** (HTTP 422 on violation): `type`, `required`, `readonly`, `min`, `max`, + `minLength`, `maxLength`, `format`, enum membership, ISO 8601 parsing for + `datetime`, the `items` of an array, and the `properties` of an object. A key the + entity does not declare is rejected. +- **In the datastore** (409 Conflict on collision): `unique` creates a SQL unique + constraint or a MongoDB unique index. ## Common Options @@ -36,19 +39,12 @@ These options apply to all field types. | Option | Required | Type | Default | Description | |--------|----------|------|---------|-------------| -| `required` | no | boolean | `false` | If `true`, the field must be present in create/replace payloads (omitting it returns 422). Relaxed for `update` (PATCH), where every field is optional. | -| `readonly` | no | boolean | `false` | If `true`, the field is dropped from all write payloads (create, update, replace). Use it for server-managed fields. | -| `default` | no | any | none | Baked into the create/replace Pydantic model. See the note below: an omitted field is NOT persisted with its default. | +| `required` | no | boolean | `false` | The field must be present in create and replace payloads unless it has a `default`. On update (PATCH) every field is optional. A required field may not be `null`. | +| `readonly` | no | boolean | `false` | Clients may not send the field; a write body that carries it gets 422. Its `default`, if any, is still stored on create. | +| `default` | no | any | none | Stored on create and replace when the payload omits the field. Checked against the field's own constraints at parse time (`FIELD_CONSTRAINT_INVALID`). | +| `unique` | no | boolean | `false` | No two records may hold the same value (409 on collision). | -### How `default` actually behaves - -The generated FastAPI backend builds your request body with `payload.model_dump(exclude_unset=True)` before inserting. So a `default:` you declare is present on the model, but if the client omits the field, it is stripped from the document and never written. The default surfaces only if you want a value echoed when constructing the model directly; it does not act as a server-side fill-in on create. - -The `stackform run` dev server does no default-filling at all and treats `required` strictly, so behavior matches: omit a field and it stays absent. - -### How `readonly` behaves - -A `readonly: true` field is excluded from the create, update, and replace request models. Clients cannot set it. It still appears in responses if your code or another path writes it (for example an external-auth provider). This is how a spec marks server-managed fields like `externalSubject`, `lastLoginAt`, and `isActive`. +A field that is neither required nor given a default may be omitted or sent as `null`. ## `type: id` @@ -67,8 +63,8 @@ id: { type: id, format: uuidv4 } # explicit uuidv4 ### Behavior - **Parse time:** If the entity declares no `type: id` field and no `pk:`, generation fails with `ID_FIELD_MISSING`. There is no implicit `id` injection. -- **Compile time:** The `id` field is excluded from all write request models (create, update, replace). -- **Runtime:** The server generates the value on create and returns it in all responses. `uuidv7` uses the stdlib `uuid.uuid7` where available and falls back to the `uuid_utils` package; `uuidv4` uses the stdlib. +- **Write:** The `id` field is never accepted in a write body. +- **Runtime:** The server generates the value on create and returns it in all responses. ## `type: string` @@ -82,13 +78,13 @@ bio: { type: string, default: "" } | Option | Required | Type | Default | Description | |--------|----------|------|---------|-------------| -| `format` | no | `"email"`, `"e164"`, `"uri"` | none | Documentation/OpenAPI hint only. NOT validated at runtime. | -| `minLength` | no | integer | none | Minimum character count (enforced, 422 on violation). | -| `maxLength` | no | integer | none | Maximum character count (enforced, 422 on violation). | +| `format` | no | `"email"`, `"e164"`, `"uri"`, `"uuidv4"`, `"uuidv7"` | none | The value must have this format (422 otherwise). | +| `minLength` | no | integer | none | Minimum character count (422 on violation). | +| `maxLength` | no | integer | none | Maximum character count (422 on violation). | ## `type: number` -Integer or floating-point number. +Integer or floating-point number. A boolean is not a number. A value is stored and returned as it was sent: `5` comes back as `5` and `5.5` as `5.5`, on every backend and datastore. ```yaml age: { type: number } @@ -98,8 +94,8 @@ viewCount: { type: number, default: 0 } | Option | Required | Type | Default | Description | |--------|----------|------|---------|-------------| -| `min` | no | number | none | Minimum value, inclusive (enforced, 422 on violation). | -| `max` | no | number | none | Maximum value, inclusive (enforced, 422 on violation). | +| `min` | no | number | none | Minimum value, inclusive (422 on violation). | +| `max` | no | number | none | Maximum value, inclusive (422 on violation). | ## `type: boolean` @@ -121,13 +117,15 @@ createdAt: { type: datetime, readonly: true } expiresAt: { type: datetime, required: true } ``` -Clients send and receive RFC 3339 strings (for example `"2024-01-15T10:00:00Z"`). The value is validated by parsing with `datetime.fromisoformat`; a string that does not parse is rejected with 422. The value itself is persisted as the ISO string, not converted to a BSON Date. +Clients send ISO 8601 strings (for example `"2024-01-15T10:00:00Z"`). A string that does +not parse is rejected with 422. The value is stored as a UTC ISO 8601 string; a value +without an offset is taken as UTC. No type-specific options. ## `type: enum` -One of a fixed set of string values. +One of a fixed set of values. ```yaml status: @@ -138,10 +136,10 @@ status: | Option | Required | Type | Default | Description | |--------|----------|------|---------|-------------| -| `values` | yes | list of strings | -- | The allowed values. Must be non-empty (`values` is required for enum fields). | -| `default` | no | string | none | A value to bake into the model. Not range-checked against `values` at parse time. | +| `values` | yes | non-empty list | -- | The allowed values. | -A create or update payload sending a value outside `values` is rejected with HTTP 422. +A `default` outside `values` fails generation with `FIELD_CONSTRAINT_INVALID`. A +payload value outside `values` is rejected with 422. ## `type: array` @@ -157,22 +155,30 @@ scores: type: array items: type: number + min: 0 ``` | Option | Required | Type | Default | Description | |--------|----------|------|---------|-------------| -| `items` | yes | field config | -- | Schema for array elements. `items.type` may be any scalar type; only the element type is applied. | +| `items` | yes | field config | -- | Config every element must satisfy: its `type` and the constraints of that type. `required`, `readonly` and `default` describe a property, not an element, and are rejected inside `items`. | ## `type: object` -A free-form JSON object. No nested schema validation is performed on its contents. +A JSON object. ```yaml metadata: { type: object } -settings: { type: object } +address: + type: object + properties: + street: { type: string, required: true } + zip: { type: string, maxLength: 10 } ``` -No type-specific options. +| Option | Required | Type | Default | Description | +|--------|----------|------|---------|-------------| +| `properties` | no | map of field configs | none | The object's keys, each checked like a top-level field. | +| `additionalProperties` | no | boolean | `false` with `properties`, else `true` | Whether keys outside `properties` are accepted. | ## `type: ref` @@ -185,24 +191,30 @@ myRefField: { type: ref, ref: myOtherEntity, required: true } | Option | Required | Type | Default | Description | |--------|----------|------|---------|-------------| -| `ref` | yes | string (entity name) | -- | Names the target entity. Used by the UI generator to resolve relations; see Behavior. | +| `ref` | yes | string (entity name) | -- | Names the target entity. | ### Behavior -- **Type:** A `ref` field is generated as a plain `str` and validated only as a string. There is no UUID-format check on the value. -- **Parse time:** For ordinary `ref` fields the target name is NOT validated. Pointing `ref:` at a non-existent or abstract entity does not fail generation. Only `ui.collections` from-path traversal validates ref targets, and only repository `entity:` bindings reject abstract entities (`DESUGAR_ABSTRACT_REFERENCED`). -- **Runtime:** The stored value is the raw id string. No automatic join is performed; relations are dereferenced only by the UI layer or your own queries. +- **Parse time:** `ref` must name a declared entity, abstract or not + (`REF_TARGET_NOT_FOUND`), at any nesting depth. +- **Runtime:** The value is checked as a string and stored as the raw id. No join is + performed and a dangling reference is not rejected. ## Examples ### Entity with all field types ```yaml +version: "0.2" + api: basePath: /api version: v1 entities: + user: + fields: + id: { type: id } myEntity: fields: id: { type: id } @@ -222,6 +234,10 @@ entities: type: string metadata: { type: object } ownerId: { type: ref, ref: user } + +repositories: + myCollection: + entity: myEntity ``` With this `api:` block a create is `POST /api/v1/my-collection` (the route base is the repository name; see [Entities overview](./overview.md)). @@ -236,7 +252,8 @@ status: required: true ``` -Because `required: true`, the field must be sent on create; if it is omitted the request is rejected with 422 (the declared `default` is stripped by `exclude_unset` and does not satisfy the requirement). To make a field optional with a server-side value, write your own logic or a custom handler -- a bare `default:` does not auto-fill on create. +A create that omits `status` stores `draft`. A create that sends `status: null` is +rejected with 422. ## Field create-request lifecycle @@ -244,46 +261,30 @@ How a single field value flows through a create request: ```mermaid flowchart TD - A[Field in create payload] --> B{Declared in fields?} + A[Key in create payload] --> B{Declared in fields?} B -- no --> R1[Rejected: Unknown fields 422] B -- yes --> C{readonly?} - C -- yes --> D[Dropped from write model] - C -- no --> E{Value provided?} - E -- no --> F{required?} - F -- yes --> R2[Rejected: Missing required field 422] - F -- no --> G[Field omitted from document] - E -- yes --> H{type + min/max/length + enum OK?} + C -- yes --> R5[Rejected: Readonly fields 422] + C -- no --> H{type + constraints + format OK?} H -- no --> R3[Rejected 422] - H -- yes --> I[format accepted as metadata] - I --> J{unique constraint satisfied?} + H -- yes --> J{unique constraint satisfied?} J -- no --> R4[Rejected: 409 Conflict] J -- yes --> K[Value stored] - G2[type: id field] --> L[Server-generated uuid, not client-set] + M[Declared field not in payload] --> N{default?} + N -- yes --> K + N -- no --> F{required?} + F -- yes --> R2[Rejected: Missing required fields 422] + F -- no --> G[Field omitted from document] + G2[type: id field] --> L[Server-generated UUID] ``` -Two things the diagram makes concrete: a declared `default` is NOT persisted when -the field is omitted, and `format` remains metadata while `unique` is enforced by -the datastore. - ## Edge Cases -- **`required: true` with `default:`** -- The field must still be sent on create; the default is stripped before insert and does not satisfy the requirement. - -- **`unique: true`** -- Declarative and UI-only. No MongoDB index is created and duplicates are NOT rejected on write. Use it to signal intent to the generated UI, not as a uniqueness guarantee. - -- **`format`** -- A documentation/OpenAPI hint. `email`/`uri`/`e164` are never validated by the running app; an invalid email passes. - -- **`type: id` -- count rules.** Zero `id` fields and no `pk:` fails with `ID_FIELD_MISSING`. Two or more `id` fields fail with `ID_FIELD_MULTIPLE`. An invalid `format` fails with `ID_FORMAT_INVALID`. - -- **`type: id` -- never in payloads.** The `id` field is never accepted in any write body. It is server-generated and returned in responses. - -- **`type: ref` -- no referential integrity.** The value is a plain string; MongoDB enforces no foreign keys. Dangling references are possible. - -- **`type: ref` -- target not validated.** An unknown or abstract entity name in `ref:` does not fail generation for a plain field. +- **`type: id` -- count rules.** Zero `id` fields and no `pk:` fails with `ID_FIELD_MISSING`. Two or more `id` fields fail with `ID_FIELD_MULTIPLE`. A `format` other than `uuidv7` or `uuidv4` fails with `SCHEMA_VALIDATION_ERROR`. -- **`type: enum` -- invalid payload value.** A value outside `values` is rejected with HTTP 422. +- **`type: ref` -- no referential integrity.** The datastore enforces no foreign keys. Dangling references are possible. -- **`type: object` / `type: array`** -- Stored as-is. Objects get no nested schema validation; arrays validate only that the value is a list. +- **Update (PATCH).** Every field is optional and no default is filled in; the fields sent are checked as on create. ## Errors @@ -292,8 +293,10 @@ the datastore. | Field name not matching `[a-zA-Z][a-zA-Z0-9_]*` | `INVALID_FIELD_NAME` | "Invalid field name ''" | | No `type: id` field and no `pk:` | `ID_FIELD_MISSING` | "Entity must define exactly one field with type 'id'" | | Two or more `type: id` fields on one entity | `ID_FIELD_MULTIPLE` | "Entity must define exactly one field with type 'id'" | -| `type: id` with invalid `format` | `ID_FORMAT_INVALID` | "id field format must be uuidv7 or uuidv4" | +| A `default` that breaks the field's own constraints | `FIELD_CONSTRAINT_INVALID` | "default '' is invalid: ..." | +| `ref:` naming an undeclared entity | `REF_TARGET_NOT_FOUND` | "Field references undeclared record type ''" | | `pk:` naming a field not in the entity | `PK_FIELD_NOT_FOUND` | "pk field '' not found in entity ''" | +| An unknown option, or an option of the wrong shape | `SCHEMA_VALIDATION_ERROR` | | ## See Also diff --git a/docs/v0.2/Reference/entities/inheritance.md b/docs/v0.2/Reference/entities/inheritance.md index 22ab3ce2..2dc0c595 100644 --- a/docs/v0.2/Reference/entities/inheritance.md +++ b/docs/v0.2/Reference/entities/inheritance.md @@ -67,7 +67,7 @@ Marks an entity as a template. Abstract entities: - Cannot be bound to a repository (a repository's `entity:` pointing at an abstract entity raises `DESUGAR_ABSTRACT_REFERENCED`). - Are removed before route generation. -A field with `type: ref` may still name an abstract entity as its target; `ref:` targets are not validated against the abstract/concrete distinction. Only repository `entity:` bindings are checked. +A field with `type: ref` may name an abstract entity as its target; `ref:` must only name a declared entity (`REF_TARGET_NOT_FOUND`). Only repository `entity:` bindings reject abstract entities. ## Primary key requirement @@ -167,7 +167,7 @@ The child's `name` field completely replaces the parent's. `user` has `id` and ` - **Abstract entities cannot be referenced by repositories.** If a repository's `entity:` points to an abstract entity, the parser raises `DESUGAR_ABSTRACT_REFERENCED`. -- **Abstract `ref:` targets are not validated.** A `type: ref` field may name an abstract entity. The abstract entity is simply removed during resolution; no error is raised for the dangling reference. +- **Abstract `ref:` targets.** A `type: ref` field may name an abstract entity. A name that is not a declared entity raises `REF_TARGET_NOT_FOUND`. - **Circular inheritance.** If `a extends b` and `b extends a` (directly or transitively), the parser raises `ENTITY_CIRCULAR_EXTENDS`. diff --git a/docs/v0.2/Reference/entities/overview.md b/docs/v0.2/Reference/entities/overview.md index a2ce2eeb..85e400ec 100644 --- a/docs/v0.2/Reference/entities/overview.md +++ b/docs/v0.2/Reference/entities/overview.md @@ -16,16 +16,21 @@ entities: extends: parentName # optional type: auth # optional, default: base ("auth" marks an identity provider) pk: fieldName # optional, natural primary key - auth: # required in practice when type: auth + auth: # required with type: auth identifier: fieldName - via: password # or apiKey, or [password, apiKey] - roles: enumFieldName # optional, names an enum field that holds the role - bindings: # optional, external identity providers (OIDC/SAML) + via: # a provider name, a list of names, or a list of binding objects + - provider: password - provider: providerName identifier: fieldName + roles: enumFieldName # optional, names an enum field that holds the role + roles: # optional, only with type: auth + values: [roleA, roleB] + default: roleB + hierarchy: + roleA: [roleB] fields: fieldName: - type: + type: string # see Field types # ... field options ``` @@ -37,12 +42,13 @@ entities: | `extends` | no | string (entity name) | none | Inherits fields, `type`, and `auth` from the named parent entity. | | `type` | no | `"base"` or `"auth"` | `"base"` | `"auth"` marks the entity as an identity provider. | | `pk` | no | string (field name) | none | Designates a field as the natural primary key. | -| `auth` | effectively required if `type: auth` | object | none | Authentication configuration block. | +| `auth` | yes with `type: auth` | object | none | Authentication configuration block. | +| `roles` | no; only with `type: auth` | object | none | Declared roles, their default and hierarchy. | | `fields` | required unless inheriting via `extends:` | map | none | Field definitions. | ### `abstract` -When `true`, the entity is a template only. It does not produce a collection and cannot be bound to a repository. Abstract entities are removed during desugaring; only a repository's `entity:` binding is checked against them, raising `DESUGAR_ABSTRACT_REFERENCED` if a repository points at one. A plain `ref:` field that names an abstract (or unknown) entity is not validated. +When `true`, the entity is a template only. It does not produce a collection and cannot be bound to a repository. Abstract entities are removed during desugaring; only a repository's `entity:` binding is checked against them, raising `DESUGAR_ABSTRACT_REFERENCED` if a repository points at one. A `ref:` field may name an abstract entity; an unknown name raises `REF_TARGET_NOT_FOUND`. ### `extends` @@ -50,7 +56,7 @@ Inherits from the named entity: all `fields`, and also the parent's `type` and ` ### `type` -`"base"` (the default) is an ordinary entity. `"auth"` marks the entity as an identity provider, enabling authentication behavior (login, registration, token issuance). In practice this requires an `auth:` block. See [auth overview](../auth/overview.md). +`"base"` (the default) is an ordinary entity. `"auth"` marks the entity as an identity provider, enabling authentication behavior (login, registration, token issuance) configured by its `auth:` block. See [auth overview](../auth/overview.md). ### `pk` @@ -58,11 +64,30 @@ Designates an existing field as the natural primary key. When `pk:` is set, no s ### `auth` -Authentication configuration for `type: auth` entities. Holds `identifier`, `via`, an optional `roles` (the name of an enum field carrying the role), and optional `bindings` for external providers. The `password` and `passwordHash` fields are implicit when `via` includes `password` -- declaring them yourself is rejected. See [auth overview](../auth/overview.md). +Authentication configuration for `type: auth` entities. Holds `identifier`, `via` (credential providers and binding objects for external providers), and an optional `roles` (the name of an enum field carrying the role). The `password` and `passwordHash` fields are implicit when `via` includes `password` -- declaring them yourself is rejected. See [auth overview](../auth/overview.md). ### `roles` -There is no entity-level `roles:` block in v0.2. To give an auth entity roles, declare an `enum` field and point `auth.roles` at it: +An auth entity holds roles in one of two ways. A `roles:` block declares managed roles, kept out of the entity's fields: + +```yaml +entities: + user: + type: auth + auth: + identifier: email + via: password + roles: + values: [admin, editor, viewer] + default: viewer + hierarchy: + admin: [editor] + fields: + id: { type: id } + email: { type: string, required: true, unique: true } +``` + +Or `auth.roles` names an `enum` field that holds the role: ```yaml entities: @@ -81,7 +106,7 @@ entities: default: viewer ``` -The role value is read from that enum field at request time. The new-user default comes from the enum field's own `default:`, not from any separate role config. Role hierarchy is configured by a separate top-level `roles:` map (a sibling of `entities:`), keyed by auth-entity name; that hierarchy is parsed but not yet applied during enforcement -- only exact role matches are checked. See [roles overview](../roles/overview.md). +Roles are read at request time, from the enum field or from the managed store. A `roles:` block may accompany the enum field to add a hierarchy. See [roles overview](../roles/overview.md). ### `fields` @@ -89,9 +114,9 @@ Map of field name to field definition. Required unless the entity fully inherits ## Behavior -- **Parse time:** Entity names are validated for uniqueness. `extends:` references are resolved (fields, `type`, and `auth` inherited) and circular chains are rejected. Abstract entities are stripped during desugaring. An auth entity's `auth.roles` must name an existing `enum` field. +- **Parse time:** Entity names are validated for uniqueness. `extends:` references are resolved (fields, `type`, and `auth` inherited) and circular chains are rejected. Abstract entities are stripped during desugaring. An auth entity's `auth.roles` must name an existing `enum` field, and a `roles:` block must keep its default and hierarchy within its `values`. - **Compile time:** The collection and resource name come from the **repository** name that binds the entity (free-form; no pluralization). An entity referenced by two repositories produces two collections. -- **Runtime:** Each repository-bound entity produces a collection. Roles are read from the configured enum field. +- **Runtime:** Each repository-bound entity produces a collection. Roles are read from the configured enum field or the managed store. ## Examples @@ -134,12 +159,12 @@ entities: type: enum values: [admin, moderator, user] default: user - -# Optional hierarchy (UI/declarative only; not applied during enforcement) -roles: - user: - admin: [moderator] - moderator: [user] + roles: + values: [admin, moderator, user] + default: user + hierarchy: + admin: [moderator] + moderator: [user] ``` ### Inheritance with abstract base @@ -191,6 +216,11 @@ entities: | Repository binds an abstract entity | `DESUGAR_ABSTRACT_REFERENCED` | "Repository 'X' references abstract entity 'Y'" | | `auth.roles` names a missing field | `AUTH_ROLES_FIELD_NOT_FOUND` | "Roles field 'X' not found in entity 'Y'" | | `auth.roles` field is not an enum | `AUTH_ROLES_FIELD_NOT_ENUM` | "Roles field 'X' must be of type 'enum'" | +| `roles.default` is not in `roles.values` | `ROLES_DEFAULT_UNKNOWN_ROLE` | "Default role 'X' is not one of the declared roles" | +| `roles.hierarchy` names an undeclared role | `ROLES_HIERARCHY_UNKNOWN_ROLE` | "Role hierarchy references undeclared role 'X'" | +| `roles.hierarchy` is cyclic | `ROLES_HIERARCHY_CYCLE` | "Role hierarchy for 'Y' is cyclic through 'X'" | +| `ref:` names an undeclared entity | `REF_TARGET_NOT_FOUND` | "Field references undeclared record type 'X'" | +| `type: auth` without `auth`, or `roles` without `type: auth` | `SCHEMA_VALIDATION_ERROR` | | | Declaring `password` on an auth entity | `AUTH_PASSWORD_FIELD_FORBIDDEN` | "Password input is implicit when auth.via includes password" | | Declaring `passwordHash` on an auth entity | `AUTH_PASSWORD_HASH_FIELD_FORBIDDEN` | "passwordHash is implicit storage when auth.via includes password" | diff --git a/docs/v0.2/Reference/entities/primary-keys.md b/docs/v0.2/Reference/entities/primary-keys.md index 15abf472..b57582a6 100644 --- a/docs/v0.2/Reference/entities/primary-keys.md +++ b/docs/v0.2/Reference/entities/primary-keys.md @@ -54,7 +54,7 @@ flowchart TD F -- 1 --> I{format value} I -- omitted --> J[uuidv7 default] I -- uuidv7 / uuidv4 --> K[explicit format] - I -- other --> L[ID_FORMAT_INVALID] + I -- other --> L[SCHEMA_VALIDATION_ERROR] ``` ## Behavior @@ -170,7 +170,7 @@ curl -X POST http://localhost:8000/api/v1/my-collection \ | `pk:` references a non-existent field | `PK_FIELD_NOT_FOUND` | "pk field 'X' not found in entity 'Y'" | | No `type: id` field and no `pk:` | `ID_FIELD_MISSING` | "Entity must define exactly one field with type 'id'" | | Two or more `type: id` fields | `ID_FIELD_MULTIPLE` | "Entity must define exactly one field with type 'id'" | -| Invalid `format` on `type: id` | `ID_FORMAT_INVALID` | "id field format must be uuidv7 or uuidv4" | +| Invalid `format` on `type: id` | `SCHEMA_VALIDATION_ERROR` | "'' is not one of ['uuidv7', 'uuidv4']" | ## See Also diff --git a/docs/v0.2/Reference/grammar.md b/docs/v0.2/Reference/grammar.md new file mode 100644 index 00000000..9ebea3ab --- /dev/null +++ b/docs/v0.2/Reference/grammar.md @@ -0,0 +1,292 @@ +--- +title: "Grammar Reference" +description: Every key a version 0.2 spec accepts, with its type and the page that documents it. +--- + +Every key a `version: "0.2"` spec accepts. `` stands for a name you choose. A key outside this list fails schema validation with `SCHEMA_VALIDATION_ERROR`. + + +## Version + +| Key | Type | Reference | +|-----|------|-----------| +| `version` | `"0.2"` | [concepts](../concepts.md) | + +## API + +| Key | Type | Reference | +|-----|------|-----------| +| `api` | object | [configuration/api](configuration/api.md) | +| `api.basePath` | string | [configuration/api](configuration/api.md) | +| `api.version` | string | [configuration/api](configuration/api.md) | +| `api.port` | integer | [configuration/api](configuration/api.md) | +| `api.operations` | object | [configuration/api](configuration/api.md) | +| `api.operations.defaults` | object | [configuration/api](configuration/api.md) | +| `api.operations.defaults.list` | boolean | [configuration/api](configuration/api.md) | +| `api.operations.defaults.get` | boolean | [configuration/api](configuration/api.md) | +| `api.operations.defaults.create` | boolean | [configuration/api](configuration/api.md) | +| `api.operations.defaults.update` | boolean | [configuration/api](configuration/api.md) | +| `api.operations.defaults.replace` | boolean | [configuration/api](configuration/api.md) | +| `api.operations.defaults.delete` | boolean | [configuration/api](configuration/api.md) | +| `api.operations.auth` | object | [configuration/api](configuration/api.md) | +| `api.operations.auth.register` | boolean | [configuration/api](configuration/api.md) | +| `api.operations.auth.login` | boolean | [configuration/api](configuration/api.md) | +| `api.cors` | object | [configuration/api](configuration/api.md) | +| `api.cors.origins` | list of strings | [configuration/api](configuration/api.md) | +| `api.cors.methods` | list of strings | [configuration/api](configuration/api.md) | +| `api.cors.headers` | list of strings | [configuration/api](configuration/api.md) | +| `api.cors.credentials` | boolean | [configuration/api](configuration/api.md) | +| `api.securityHeaders` | boolean | [configuration/api](configuration/api.md) | + +## Imports + +| Key | Type | Reference | +|-----|------|-----------| +| `imports` | map | [services/overview](services/overview.md#imports) | + +## MongoDB + +| Key | Type | Reference | +|-----|------|-----------| +| `mongo` | object | [configuration/mongo](configuration/mongo.md) | +| `mongo.uri` | string | [configuration/mongo](configuration/mongo.md) | +| `mongo.database` | string | [configuration/mongo](configuration/mongo.md) | + +## Datastores + +| Key | Type | Reference | +|-----|------|-----------| +| `datastores` | map | [configuration/datastores](configuration/datastores.md) | +| `datastores..type` | `mongodb` \| `mongo` \| `postgres` \| `postgresql` | [configuration/datastores](configuration/datastores.md) | +| `datastores..uri` | string | [configuration/datastores](configuration/datastores.md) | +| `datastores..database` | string | [configuration/datastores](configuration/datastores.md) | +| `datastores..dsn` | string | [configuration/datastores](configuration/datastores.md) | +| `datastores..default` | boolean | [configuration/datastores](configuration/datastores.md) | + +## Entities + +| Key | Type | Reference | +|-----|------|-----------| +| `entities` | map | [entities/overview](entities/overview.md) | +| `entities..type` | `base` \| `auth` | [entities/overview](entities/overview.md) | +| `entities..extends` | string | [entities/inheritance](entities/inheritance.md) | +| `entities..abstract` | boolean | [entities/inheritance](entities/inheritance.md) | +| `entities..pk` | string | [entities/primary-keys](entities/primary-keys.md) | +| `entities..fields` | map | [entities/field-types](entities/field-types.md) | +| `entities..fields..type` | `string` \| `number` \| `boolean` \| `datetime` \| `enum` \| `array` \| `object` \| `ref` \| `id` | [entities/field-types](entities/field-types.md) | +| `entities..fields..required` | boolean | [entities/field-types](entities/field-types.md) | +| `entities..fields..readonly` | boolean | [entities/field-types](entities/field-types.md) | +| `entities..fields..default` | any | [entities/field-types](entities/field-types.md) | +| `entities..fields..minLength` | integer | [entities/field-types](entities/field-types.md) | +| `entities..fields..maxLength` | integer | [entities/field-types](entities/field-types.md) | +| `entities..fields..format` | `email` \| `e164` \| `uri` \| `uuidv7` \| `uuidv4` | [entities/field-types](entities/field-types.md) | +| `entities..fields..min` | number | [entities/field-types](entities/field-types.md) | +| `entities..fields..max` | number | [entities/field-types](entities/field-types.md) | +| `entities..fields..values` | list of strings | [entities/field-types](entities/field-types.md) | +| `entities..fields..items` | object | [entities/field-types](entities/field-types.md) | +| `entities..fields..items.*` | a nested field, with the keys of `fields.` | [entities/field-types](entities/field-types.md) | +| `entities..fields..properties` | map | [entities/field-types](entities/field-types.md) | +| `entities..fields..properties..*` | a nested field, with the keys of `fields.` | [entities/field-types](entities/field-types.md) | +| `entities..fields..additionalProperties` | boolean | [entities/field-types](entities/field-types.md) | +| `entities..fields..ref` | string | [entities/field-types](entities/field-types.md) | +| `entities..fields..unique` | boolean | [entities/field-types](entities/field-types.md) | +| `entities..auth` | object | [auth/overview](auth/overview.md) | +| `entities..auth.identifier` | string | [auth/overview](auth/overview.md) | +| `entities..auth.via` | string or list of strings or list of objects | [auth/login-and-sso](auth/login-and-sso.md) | +| `entities..auth.via.provider` | string | [auth/login-and-sso](auth/login-and-sso.md) | +| `entities..auth.via.identifier` | string | [auth/login-and-sso](auth/login-and-sso.md) | +| `entities..auth.via.claim_mapping` | map | [auth/login-and-sso](auth/login-and-sso.md) | +| `entities..auth.via.auto_provision` | boolean | [auth/login-and-sso](auth/login-and-sso.md) | +| `entities..auth.via.relations` | map | [auth/login-and-sso](auth/login-and-sso.md) | +| `entities..auth.via.relations..collection` | string | [auth/login-and-sso](auth/login-and-sso.md) | +| `entities..auth.via.relations..match` | map | [auth/login-and-sso](auth/login-and-sso.md) | +| `entities..auth.roles` | string | [auth/overview](auth/overview.md) | +| `entities..roles` | object | [roles/overview](roles/overview.md) | +| `entities..roles.values` | list of strings | [roles/overview](roles/overview.md) | +| `entities..roles.default` | string | [roles/overview](roles/overview.md) | +| `entities..roles.hierarchy` | map | [roles/overview](roles/overview.md) | + +## Repositories + +| Key | Type | Reference | +|-----|------|-----------| +| `repositories` | map | [repositories/overview](repositories/overview.md) | +| `repositories..softDelete` | string | [repositories/lifecycle](repositories/lifecycle.md) | +| `repositories..ttl` | object | [repositories/lifecycle](repositories/lifecycle.md) | +| `repositories..ttl.field` | string | [repositories/lifecycle](repositories/lifecycle.md) | +| `repositories..ttl.seconds` | integer | [repositories/lifecycle](repositories/lifecycle.md) | +| `repositories..abstract` | boolean | [repositories/overview](repositories/overview.md) | +| `repositories..extends` | string | [repositories/overview](repositories/overview.md) | +| `repositories..entity` | string | [repositories/overview](repositories/overview.md) | +| `repositories..datastore` | string | [repositories/overview](repositories/overview.md) | +| `repositories..crud` | list of `list` \| `get` \| `create` \| `update` \| `replace` \| `delete` | [repositories/composable-crud](repositories/composable-crud.md) | +| `repositories..operations` | map | [repositories/operations](repositories/operations.md#custom-operations) | +| `repositories..operations..query` | object | [repositories/operations](repositories/operations.md#custom-operations) | +| `repositories..operations..query.command` | string | [repositories/operations](repositories/operations.md#custom-operations) | +| `repositories..operations..query.collection` | string | [repositories/operations](repositories/operations.md#custom-operations) | +| `repositories..operations..query.key` | string | [repositories/operations](repositories/operations.md#custom-operations) | +| `repositories..operations..query.sql` | string | [repositories/operations](repositories/operations.md#custom-operations) | +| `repositories..operations..query.filter` | any | [repositories/operations](repositories/operations.md#custom-operations) | +| `repositories..operations..query.pipeline` | any | [repositories/operations](repositories/operations.md#custom-operations) | +| `repositories..operations..query.document` | any | [repositories/operations](repositories/operations.md#custom-operations) | +| `repositories..operations..query.documents` | any | [repositories/operations](repositories/operations.md#custom-operations) | +| `repositories..operations..query.update` | any | [repositories/operations](repositories/operations.md#custom-operations) | +| `repositories..operations..query.replacement` | any | [repositories/operations](repositories/operations.md#custom-operations) | +| `repositories..operations..query.requests` | any | [repositories/operations](repositories/operations.md#custom-operations) | +| `repositories..operations..query.params` | map | [repositories/operations](repositories/operations.md#custom-operations) | +| `repositories..operations..handler` | string | [repositories/operations](repositories/operations.md#custom-operations) | +| `repositories..operations..includeDeleted` | boolean, with `query` only | [repositories/lifecycle](repositories/lifecycle.md#includedeleted) | + +## Services + +| Key | Type | Reference | +|-----|------|-----------| +| `services` | map | [services/overview](services/overview.md) | +| `services..abstract` | boolean | [services/overview](services/overview.md#inheritance-with-abstract-and-extends) | +| `services..extends` | string | [services/overview](services/overview.md#inheritance-with-abstract-and-extends) | +| `services..repository` | string | [services/overview](services/overview.md) | +| `services..operation` | string | [services/overview](services/overview.md) | +| `services..handler` | string | [services/overview](services/overview.md) | +| `services..operations` | map | [services/overview](services/overview.md#per-operation-overrides) | +| `services..operations..handler` | string | [services/overview](services/overview.md#per-operation-overrides) | + +## Controllers + +| Key | Type | Reference | +|-----|------|-----------| +| `controllers` | map | [controllers/overview](controllers/overview.md) | +| `controllers..service` | string | [controllers/overview](controllers/overview.md) | +| `controllers..base_path` | string | [controllers/overview](controllers/overview.md) | +| `controllers..access` | string or list of strings or objects | [controllers/access-control](controllers/access-control.md) | +| `controllers..access.given` | map | [controllers/access-control](controllers/access-control.md) | +| `controllers..access.when` | string or list of strings | [controllers/access-control](controllers/access-control.md) | +| `controllers..access.then` | `allow` \| `deny` | [controllers/access-control](controllers/access-control.md) | +| `controllers..access.reason` | string | [controllers/access-control](controllers/access-control.md) | +| `controllers..operations` | map | [controllers/operations](controllers/operations.md) | +| `controllers..operations..method` | `GET` \| `POST` \| `PUT` \| `PATCH` \| `DELETE` | [controllers/operations](controllers/operations.md) | +| `controllers..operations..path` | string | [controllers/operations](controllers/operations.md) | +| `controllers..operations..access` | string or list of strings or objects | [controllers/access-control](controllers/access-control.md) | +| `controllers..operations..access.given` | map | [controllers/access-control](controllers/access-control.md) | +| `controllers..operations..access.when` | string or list of strings | [controllers/access-control](controllers/access-control.md) | +| `controllers..operations..access.then` | `allow` \| `deny` | [controllers/access-control](controllers/access-control.md) | +| `controllers..operations..access.reason` | string | [controllers/access-control](controllers/access-control.md) | +| `controllers..routes` | map | [controllers/overview](controllers/overview.md) | +| `controllers..routes..access` | string or list of strings or objects | [controllers/access-control](controllers/access-control.md) | +| `controllers..routes..access.given` | map | [controllers/access-control](controllers/access-control.md) | +| `controllers..routes..access.when` | string or list of strings | [controllers/access-control](controllers/access-control.md) | +| `controllers..routes..access.then` | `allow` \| `deny` | [controllers/access-control](controllers/access-control.md) | +| `controllers..routes..access.reason` | string | [controllers/access-control](controllers/access-control.md) | +| `controllers..routes..service` | string | [controllers/overview](controllers/overview.md) | +| `controllers..routes..method` | `GET` \| `POST` \| `PUT` \| `PATCH` \| `DELETE` | [controllers/overview](controllers/overview.md) | +| `controllers..routes..path` | string | [controllers/overview](controllers/overview.md) | + +## Providers + +| Key | Type | Reference | +|-----|------|-----------| +| `providers` | map | [providers/overview](providers/overview.md) | +| `providers..type` | `oidc` \| `saml` | [providers/overview](providers/overview.md) | +| `providers..trust` | `app` \| `idp` | [providers/overview](providers/overview.md) | +| `providers..client_id` | string | [providers/overview](providers/overview.md) | +| `providers..client_secret` | string | [providers/overview](providers/overview.md) | +| `providers..discovery_url` | string | [providers/overview](providers/overview.md) | +| `providers..issuer` | string | [providers/overview](providers/overview.md) | +| `providers..audiences` | list of strings | [providers/overview](providers/overview.md) | +| `providers..idp_metadata_url` | string | [providers/saml](providers/saml.md) | +| `providers..sp_entity_id` | string | [providers/saml](providers/saml.md) | + +## DTOs + +| Key | Type | Reference | +|-----|------|-----------| +| `dto` | map | [dto/overview](dto/overview.md) | +| `dto..fields` | map | [dto/overview](dto/overview.md) | +| `dto..fields..type` | `string` \| `number` \| `boolean` \| `datetime` \| `enum` \| `array` \| `object` \| `ref` \| `id` | [dto/overview](dto/overview.md) | +| `dto..fields..required` | boolean | [dto/overview](dto/overview.md) | +| `dto..fields..readonly` | boolean | [dto/overview](dto/overview.md) | +| `dto..fields..default` | any | [dto/overview](dto/overview.md) | +| `dto..fields..minLength` | integer | [dto/overview](dto/overview.md) | +| `dto..fields..maxLength` | integer | [dto/overview](dto/overview.md) | +| `dto..fields..format` | `email` \| `e164` \| `uri` \| `uuidv7` \| `uuidv4` | [dto/overview](dto/overview.md) | +| `dto..fields..min` | number | [dto/overview](dto/overview.md) | +| `dto..fields..max` | number | [dto/overview](dto/overview.md) | +| `dto..fields..values` | list of strings | [dto/overview](dto/overview.md) | +| `dto..fields..items` | object | [dto/overview](dto/overview.md) | +| `dto..fields..items.*` | a nested field, with the keys of `fields.` | [dto/overview](dto/overview.md) | +| `dto..fields..properties` | map | [dto/overview](dto/overview.md) | +| `dto..fields..properties..*` | a nested field, with the keys of `fields.` | [dto/overview](dto/overview.md) | +| `dto..fields..additionalProperties` | boolean | [dto/overview](dto/overview.md) | +| `dto..fields..ref` | string | [dto/overview](dto/overview.md) | + +## Policies + +| Key | Type | Reference | +|-----|------|-----------| +| `policies` | map | [policies/overview](policies/overview.md) | +| `policies..given` | map | [policies/overview](policies/overview.md) | +| `policies..params` | list of strings | [policies/overview](policies/overview.md) | +| `policies..when` | string or list of strings | [policies/overview](policies/overview.md) | + +## UI + +| Key | Type | Reference | +|-----|------|-----------| +| `ui` | object | [ui/overview](ui/overview.md) | +| `ui.frontend` | object | [ui/frontend](ui/frontend.md) | +| `ui.frontend.pack` | string | [ui/frontend](ui/frontend.md) | +| `ui.frontend.output` | string | [ui/frontend](ui/frontend.md) | +| `ui.frontend.overwrite` | `never` \| `always` | [ui/frontend](ui/frontend.md) | +| `ui.config` | map | [ui/overview](ui/overview.md) | +| `ui.collections` | map | [ui/collections](ui/collections.md) | +| `ui.collections..fields` | map | [ui/collections](ui/collections.md) | +| `ui.collections..fields..from` | string | [ui/collections](ui/collections.md) | +| `ui.collections..fields..format` | string | [ui/collections](ui/collections.md) | +| `ui.components` | map | [ui/components](ui/components.md) | +| `ui.components..props` | map | [ui/components](ui/components.md) | +| `ui.components..props..required` | boolean | [ui/components](ui/components.md) | +| `ui.components..render` | object | [ui/components](ui/components.md) | +| `ui.components..render.component` | string | [ui/components](ui/components.md) | +| `ui.components..render.props` | map | [ui/components](ui/components.md) | +| `ui.views` | map | [ui/views](ui/views.md) | +| `ui.views..path` | string | [ui/views](ui/views.md) | +| `ui.views..component` | string | [ui/views](ui/views.md) | +| `ui.views..props` | map | [ui/views](ui/views.md) | + +## Deploy + +| Key | Type | Reference | +|-----|------|-----------| +| `deploy` | object | [configuration/deploy](configuration/deploy.md) | +| `deploy.name` | string | [configuration/deploy](configuration/deploy.md) | +| `deploy.port` | integer | [configuration/deploy](configuration/deploy.md) | +| `deploy.runtime` | `docker` \| `systemd` | [configuration/deploy](configuration/deploy.md) | +| `deploy.services` | map | [configuration/deploy](configuration/deploy.md) | +| `deploy.services..image` | string | [configuration/deploy](configuration/deploy.md) | +| `deploy.services..build` | string or object | [configuration/deploy](configuration/deploy.md) | +| `deploy.services..build.context` | string | [configuration/deploy](configuration/deploy.md) | +| `deploy.services..build.dockerfile` | string | [configuration/deploy](configuration/deploy.md) | +| `deploy.services..build.args` | map | [configuration/deploy](configuration/deploy.md) | +| `deploy.services..ports` | list of integers or strings | [configuration/deploy](configuration/deploy.md) | +| `deploy.services..environment` | map or list of strings | [configuration/deploy](configuration/deploy.md) | +| `deploy.services..volumes` | list of strings | [configuration/deploy](configuration/deploy.md) | +| `deploy.services..depends_on` | list of strings or map | [configuration/deploy](configuration/deploy.md) | +| `deploy.services..depends_on..condition` | `service_started` \| `service_healthy` \| `service_completed_successfully` | [configuration/deploy](configuration/deploy.md) | +| `deploy.services..command` | string or list of strings | [configuration/deploy](configuration/deploy.md) | +| `deploy.include` | list of strings | [configuration/deploy](configuration/deploy.md) | +| `deploy.mongo` | object | [configuration/deploy](configuration/deploy.md) | +| `deploy.mongo.local` | boolean | [configuration/deploy](configuration/deploy.md) | +| `deploy.docker` | object | [configuration/deploy](configuration/deploy.md) | +| `deploy.docker.image` | string | [configuration/deploy](configuration/deploy.md) | +| `deploy.systemd` | object | [configuration/deploy](configuration/deploy.md) | +| `deploy.systemd.user` | string | [configuration/deploy](configuration/deploy.md) | +| `deploy.systemd.restart` | string | [configuration/deploy](configuration/deploy.md) | + +## Session + +| Key | Type | Reference | +|-----|------|-----------| +| `session` | object | [configuration/session](configuration/session.md) | +| `session.algorithm` | `HS256` \| `RS256` | [configuration/session](configuration/session.md) | +| `session.ttl` | integer | [configuration/session](configuration/session.md) | +| `session.refresh` | boolean | [configuration/session](configuration/session.md) | +| `session.delivery` | `token-url` \| `cookie` | [configuration/session](configuration/session.md) | diff --git a/docs/v0.2/Reference/meta.json b/docs/v0.2/Reference/meta.json index f12e2ce8..4ece3bf7 100644 --- a/docs/v0.2/Reference/meta.json +++ b/docs/v0.2/Reference/meta.json @@ -1,6 +1,7 @@ { "title": "Reference", "pages": [ + "grammar", "configuration", "entities", "dto", @@ -11,6 +12,7 @@ "controllers", "ui", "providers", + "policies", "when-expressions", "cli" ] diff --git a/docs/v0.2/Reference/policies/overview.md b/docs/v0.2/Reference/policies/overview.md index 0dabc6e0..38f57eb6 100644 --- a/docs/v0.2/Reference/policies/overview.md +++ b/docs/v0.2/Reference/policies/overview.md @@ -13,36 +13,27 @@ top-level `policies:` block and reference it as a term inside any access rule's > expanded into the referencing rule's `when:` AST before any backend is > generated, so the resulting rule looks exactly as if you had written the > expanded expression by hand. The generated FastAPI backend, the generated DRF -> backend, and the `stackform run` dev server need no policy-aware runtime: they -> evaluate the already-expanded rule. There is no new runtime primitive. +> backend, the generated raw ASGI backend, and the `stackform run` dev server +> need no policy-aware runtime: they evaluate the already-expanded rule, with its +> parameters, no-argument terms and `reason`. There is no new runtime primitive. -## Why policies exist +## Composition -Before policies, the only way to reuse access logic was to repeat a rule across -the `access:` list. But the `access:` list is an OR: every rule you add only -**widens** access. There was no way to reuse a predicate that **narrows** access --- to require a shared condition as one `and` term inside a single rule. - -Policies close that gap. A policy is a reusable predicate that composes BOTH -ways: - -- **OR across rules** -- the existing model. Each entry in an `access:` list is a - separate rule; matching any one grants access. -- **AND / OR / NOT within a single `when:`** -- the new model. A `policy:` - term sits inside one rule's `when:` and combines with other terms, so reuse can - now tighten access, not only widen it. +An `access:` list is an OR: each entry is a separate rule, and matching any one +grants access, so adding a rule only **widens** access. A `policy:` term sits +inside one rule's `when:` and combines with `and`, `or`, and `not`, so a reused +predicate can also **narrow** access. ```yaml access: - - when: "policy:isTrustiantAdmin" # OR: admins always pass + - when: "policy:isOrgAdmin and policy:ownOrg(risks)" - given: { user: identity:user, target: risks } - when: "policy:ownOrg(risks) and target.ownerId equals user.id" # AND-tightened + when: "policy:ownOrg(risks) and target.ownerId equals user.id" ``` -The second rule reuses `ownOrg` and ANDs it with an extra term drawn from the -rule's own `given:`. The OR-splice model could not express that "reuse AND an -extra condition" shape -- splicing `ownOrg` as its own list entry would have -granted access on `ownOrg` alone. +Both rules reuse `ownOrg`. The first ANDs it with another policy; the second ANDs +it with a term drawn from the rule's own `given:`. Neither grants access on +`ownOrg` alone. ## The `policies:` block @@ -51,13 +42,13 @@ top-level blocks, it is deep-merged across `include`d files. ```yaml policies: - isTrustiantAdmin: + isOrgAdmin: given: { user: identity:user } - when: "'trustiant-admin' in user.roles" + when: "user.record.role equals 'org-admin'" ownOrg: given: { user: identity:user } params: [target] - when: "'org-admin' in user.roles and target.organizationId equals user.record.organizationId" + when: "target.organizationId equals user.record.organizationId" ``` ### Policy fields @@ -100,15 +91,15 @@ binding := identity: | request: | | generation aborts with `POLICY_ARITY_MISMATCH`. Args bind to `params` positionally; there are no keyword or defaulted args. - A policy with no `params` is referenced **bare**, without parentheses: - `policy:isTrustiantAdmin`. Supplying args to a no-params policy is a + `policy:isOrgAdmin`. Supplying args to a no-params policy is a `POLICY_ARITY_MISMATCH`. - A reference to an undefined policy aborts with `POLICY_NOT_FOUND`. ```yaml -when: "policy:isTrustiantAdmin" # bare, no params +when: "policy:isOrgAdmin" # bare, no params when: "policy:ownOrg(request:riskCategory)" # one positional arg when: "policy:ownOrg(riskCategories)" # arg is a fetched document -when: "policy:isTrustiantAdmin or policy:ownOrg(risks)" # composed with or +when: "policy:isOrgAdmin and policy:ownOrg(risks)" # composed with and ``` ## Param-arg record normalization @@ -159,14 +150,49 @@ rule; a document symbol makes it a document rule. After expansion no ## Worked example ```yaml +version: "0.2" + +entities: + user: + type: auth + auth: + identifier: email + via: password + roles: role + fields: + id: { type: id } + email: { type: string, required: true, unique: true } + organizationId: { type: string, required: true } + role: { type: enum, values: [org-admin, member], default: member } + riskCategory: + fields: + id: { type: id } + name: { type: string, required: true } + organizationId: { type: string, required: true } + risk: + fields: + id: { type: id } + title: { type: string, required: true } + organizationId: { type: string, required: true } + ownerId: { type: string, required: true } + +repositories: + users: { entity: user } + riskCategories: { entity: riskCategory } + risks: { entity: risk } + +services: + riskCategories: { repository: riskCategories } + risks: { repository: risks } + policies: - isTrustiantAdmin: + isOrgAdmin: given: { user: identity:user } - when: "'trustiant-admin' in user.roles" + when: "user.record.role equals 'org-admin'" ownOrg: given: { user: identity:user } params: [target] - when: "'org-admin' in user.roles and target.organizationId equals user.record.organizationId" + when: "target.organizationId equals user.record.organizationId" controllers: riskCategories: @@ -176,12 +202,10 @@ controllers: operations: create: access: - - when: "policy:isTrustiantAdmin" - - when: "policy:ownOrg(request:riskCategory)" + - when: "policy:isOrgAdmin and policy:ownOrg(request:riskCategory)" update: access: - - when: "policy:isTrustiantAdmin" - - when: "policy:ownOrg(riskCategories)" + - when: "policy:isOrgAdmin and policy:ownOrg(riskCategories)" risks: service: risks base_path: /risks @@ -189,7 +213,7 @@ controllers: operations: delete: access: - - when: "policy:isTrustiantAdmin" + - when: "policy:isOrgAdmin and policy:ownOrg(risks)" - given: { user: identity:user, target: risks } when: "policy:ownOrg(risks) and target.ownerId equals user.id" ``` @@ -209,8 +233,8 @@ controllers: independent of the policy's `target` param -- policy and rule symbols never share scope. -In each case `isTrustiantAdmin` is a separate OR rule: an admin passes -regardless of org. +An `org-admin` acts on the records of its own organization; any other member +deletes only the risks it owns in its organization. ## Errors diff --git a/docs/v0.2/Reference/providers/oidc.md b/docs/v0.2/Reference/providers/oidc.md index 1b43205a..ce7d6745 100644 --- a/docs/v0.2/Reference/providers/oidc.md +++ b/docs/v0.2/Reference/providers/oidc.md @@ -1,11 +1,11 @@ --- title: "OIDC Provider" -description: OpenID Connect single sign-on provider configuration and the generated login/callback flow. +description: OpenID Connect single sign-on provider configuration, the generated login/callback flow, and the Keycloak audience setup for IdP-trusted tokens. --- OpenID Connect provider configuration for external SSO login. A provider is -declared once under the top-level `providers:` block, then attached to an auth -entity through `auth.bindings` (see [Entity binding](#entity-binding) below). +declared once under the top-level `providers:` block, then named by an auth +entity in `auth.via` (see [Entity binding](#entity-binding) below). ## Schema @@ -18,39 +18,35 @@ providers: discovery_url: ${OIDC_DISCOVERY_URL:-https://idp.example.com/.well-known/openid-configuration} ``` -The provider object is closed (`additionalProperties: false`): only the fields -below are accepted. Any value supports `${VAR}` interpolation, including the -`${VAR:-default}` form. Interpolation is resolved at build time and baked into the -output -- with one exception: `client_secret` is never baked. When present, it -triggers a `_CLIENT_SECRET` env var (e.g. `KEYCLOAK_CLIENT_SECRET`) backed -by a pydantic `SecretStr` setting, read at run time via -`settings._CLIENT_SECRET.get_secret_value()`. The spec must hold only a -placeholder, never the literal secret -- see -[Environment variables](../configuration/environment-variables.md) for what that -means for secrets. +The provider object is closed: only the fields below are accepted, and any other +key fails with `SCHEMA_VALIDATION_ERROR`. Any value supports `${VAR}` +interpolation, including the `${VAR:-default}` form, resolved at build time. The +generated backend reads `client_secret` at run time from a +`_CLIENT_SECRET` env var (e.g. `KEYCLOAK_CLIENT_SECRET`) backed by a +pydantic `SecretStr` setting and seeded with the spec value; keep the literal +secret out of the spec file -- see +[Environment variables](../configuration/environment-variables.md). ## Fields -The required set depends on `trust`. A `trust: app` provider (the default) drives a -login flow, so it needs `discovery_url`. A `trust: idp` provider only verifies -incoming IdP tokens and requires just `type`, `client_id`, and `discovery_url`; see -[Token trust](./overview.md#token-trust). - | Field | Required | Description | |-------|----------|-------------| | `type` | yes | Must be `oidc`. | | `trust` | no | `app` (default) or `idp`. See [Token trust](./overview.md#token-trust). | | `client_id` | yes | OAuth2 client ID from the provider's developer console. | -| `client_secret` | no | OAuth2 client secret. Omit it entirely for a public client -- the token exchange then runs without a `client_secret`, which is correct for clients the IdP marks as public. For confidential clients, its presence emits a `_CLIENT_SECRET` env var (e.g. `KEYCLOAK_CLIENT_SECRET`) read at run time via a `SecretStr` setting; the spec value is only a placeholder, never the literal secret. | -| `discovery_url` | yes (effectively) | OIDC discovery endpoint (`.../.well-known/openid-configuration`). The generated routes read `authorization_endpoint`, `token_endpoint`, and `userinfo_endpoint` from it, so login fails without it. There is no automatic inference for "well-known" provider names -- always supply it. | -| `issuer` | no | Override for the expected token `iss` (only meaningful under `trust: idp`, where it is otherwise derived from the discovery document). | -| `audience` | no | Override for the expected token `aud` (only meaningful under `trust: idp`, where it defaults to `client_id`). See [Token trust](./overview.md#token-trust). | +| `discovery_url` | yes | OIDC discovery endpoint (`.../.well-known/openid-configuration`). The generated routes read `authorization_endpoint`, `token_endpoint`, `userinfo_endpoint` and `jwks_uri` from it. There is no automatic inference for "well-known" provider names. | +| `client_secret` | no, `trust: app` only | OAuth2 client secret. Omit it for a public client; the token exchange then runs without a `client_secret`. | +| `issuer` | no, `trust: idp` only | Override for the expected token `iss`; read from the discovery document by default. | +| `audiences` | no, `trust: idp` only | The accepted token `aud` values. Defaults to `[client_id]`, and generation then warns with `TRUST_IDP_AUDIENCE_DEFAULTED`. | + +`client_secret` under `trust: idp`, and `issuer` or `audiences` under +`trust: app`, fail with `SCHEMA_VALIDATION_ERROR`. ## Custom OIDC provider Any standards-compliant OIDC identity provider works the same way -- supply its -`discovery_url` and the credentials -issued for your client. You can declare several providers side by side: +`discovery_url` and the client issued for your app. You can declare several +providers side by side: ```yaml providers: @@ -77,9 +73,9 @@ endpoints). ## Entity binding -A provider is inert until an auth entity binds to it. Attach it through -`auth.bindings` (not `auth.via`, which is reserved for the built-in `password` -and `apiKey` providers): +A provider is inert until an auth entity names it in `auth.via`. A binding object +carries the provider's own identifier, claim mapping and provisioning rule, and may +sit beside the built-in `password` provider: ```yaml api: @@ -87,12 +83,12 @@ api: version: v1 entities: - myEntity: + user: type: auth auth: identifier: email - via: password - bindings: + via: + - provider: password - provider: myoidc identifier: email claim_mapping: @@ -101,77 +97,119 @@ entities: firstName: given_name lastName: family_name auto_provision: true + fields: + id: { type: id } + email: { type: string, required: true, unique: true } + name: { type: string } + firstName: { type: string } + lastName: { type: string } ``` | Binding field | Required | Description | |---------------|----------|-------------| -| `provider` | yes | Name of a provider declared under `providers:`. | -| `identifier` | no | Local field used to find/create the user. Defaults to the entity's `auth.identifier`. | +| `provider` | yes | `password`, `apiKey`, or the name of a provider declared under `providers:`. | +| `identifier` | no | Local field used to find or create the user. Defaults to the entity's `auth.identifier`. | | `claim_mapping` | no | Maps local field names to OIDC claim names (`localField: oidcClaim`). | | `auto_provision` | no | Create the user on first login when no match is found. Defaults to `true`. | +| `relations` | no | Fills a foreign key with the id of a record whose fields match claims; see [Claim mapping](../auth/claim-mapping.md). | If `claim_mapping` does not supply the identifier, the callback falls back to -the `email`, `login`, or `sub` claim, in that order. +the `email`, `login`, or `sub` claim, in that order. A binding naming an +undeclared provider fails with `BINDING_PROVIDER_NOT_FOUND`, and an `identifier` +that is not a field of the entity fails with `BINDING_IDENTIFIER_FIELD_NOT_FOUND`. ## Generated routes -Routes are emitted only for identity collections that actually bind an OIDC -provider. Paths are prefixed with the API base path and version from your `api:` -block (`{basePath}[/{version}]`). With the binding above and -`api: { basePath: /api, version: v1 }`, the identity collection `users` -produces: +Routes are emitted only for identity collections that sign users in through an +OIDC provider. Paths are prefixed with the API base path and version from your +`api:` block (`{basePath}[/{version}]`). With the binding above, the identity +collection `users` produces: | Method | Path | Description | |--------|------|-------------| -| GET | `/api/v1/users/auth/providers` | List the external providers bound to this collection (`[{name, type}]`). | +| GET | `/api/v1/users/auth/providers` | List the external providers of this collection (`[{name, type}]`). | | GET | `/api/v1/users/auth/myoidc` | Redirect to the provider's `authorization_endpoint`. | | GET | `/api/v1/users/auth/myoidc/callback?code=...` | Handle the OAuth2 callback and issue a one-time login code. | +| GET | `/api/v1/users/auth/myoidc/logout` | Redirect to the provider's `end_session_endpoint`. | | POST | `/api/v1/auth/exchange` | Redeem a one-time login code for the app JWT. | The provider name in the path is the key from `providers:` (here `myoidc`), -and the collection segment is the repository/identity-collection name. The -`/auth/exchange` endpoint is collection-independent and emitted once whenever -any collection binds an OIDC provider. +and the collection segment is the repository name. The `/auth/exchange` +endpoint is collection-independent and emitted once. ## Login and callback flow 1. The browser hits `GET /api/v1/users/auth/myoidc`. The server reads the discovery document and 302-redirects to the provider's - `authorization_endpoint` with `response_type=code` and - `scope=openid email profile`. The `redirect_uri` is this app's own - `.../auth/myoidc/callback` URL. + `authorization_endpoint` with `response_type=code`, + `scope=openid email profile` and a `state` value. The `redirect_uri` is this + app's own `.../auth/myoidc/callback` URL. 2. The provider authenticates the user and redirects back to the callback with - a `code` query parameter. -3. The callback exchanges the code at the `token_endpoint`, then fetches the - profile from the `userinfo_endpoint` (or `api.github.com/user` for `github`). -4. Claims are mapped to local fields via `claim_mapping`; the user is matched on - `identifier`. An existing user is logged in; a new one is created when + `code` and `state` query parameters. +3. The callback checks `state`, exchanges the code at the `token_endpoint`, then + fetches the profile from the `userinfo_endpoint` (or `api.github.com/user` + for `github`). +4. Claims are converted to the types of the fields they fill and mapped through + `claim_mapping`; the user is matched on `identifier`. An existing user has + the mapped fields brought up to date; a new one is created when `auto_provision` is `true`. -5. The server mints a single-use, short-lived login code and 302-redirects to +5. The server mints a single-use, short-lived login code and redirects to `{FRONTEND_URL}/auth/callback?code=`. The app JWT never travels in a - redirect URL. When `FRONTEND_URL` is unset, the redirect falls back to this - app's own base URL. + redirect URL. When `FRONTEND_URL` is unset, the redirect goes to this app's + own base URL. 6. The single-page frontend reads the `code` query parameter and `POST`s it to `/api/v1/auth/exchange` as `{"code": ""}`. The server redeems the code - and returns `{"token": ""}`. The code is stored in an - `_oidc_login_codes` collection in the identity collection's datastore, lives - for 60 seconds, and is redeemed atomically (delete-on-read), so it works - exactly once even across workers. A missing code returns 400 `Missing code`; - an unknown or expired code returns 401 `Invalid or expired code`. + and returns `{"token": ""}`. The code lives for 60 seconds and is + redeemed atomically, so it works exactly once even across workers. -Set the `FRONTEND_URL` environment variable on the generated backend so the -final redirect lands on your UI rather than the API host. +Set the `FRONTEND_URL` environment variable on the backend so the final redirect +lands on your UI rather than the API host. ## Errors during login | Status | Condition | |--------|-----------| +| 400 `Invalid OIDC state` | The callback's `state` does not match the login that started it. | | 401 `Token exchange failed` | The `token_endpoint` rejected the authorization code. | | 401 `Failed to fetch user info` | The `userinfo` (or GitHub user) request failed. | | 401 `Token expired` | The provider returned an already-expired `exp` claim. | +| 401 `The provider's claims carry no usable ''` | No claim yields a value the identifier field can hold. | +| 401 | Signing in would give the record a unique value another record already holds. | | 403 `User not found and auto-provisioning is disabled` | No matching user and `auto_provision: false`. | | 400 `Missing code` | `/auth/exchange` was called without a `code`. | -| 401 `Invalid or expired code` | The login code is unknown, already redeemed, or past its 60-second TTL. | +| 401 `Invalid or expired code` | The login code is unknown, already redeemed, or past its 60-second lifetime. | + +## Keycloak: putting the API in `aud` + +Under `trust: idp` the app verifies the access token's `aud` against `audiences` +(or `client_id`). A Keycloak access token names an audience only through protocol +mappers, so by default it does not carry the API and every request answers +`401 Invalid IdP token`. Add an **Audience** mapper so every access token the +clients receive names the API: + +1. In the realm, open **Client scopes** and create a scope, for example + `api-audience`. +2. In its **Mappers** tab choose **Configure a new mapper**, then **Audience**. +3. Set **Included Client Audience** to the API's client (the provider's + `client_id`), or **Included Custom Audience** to a value listed in + `audiences`, and turn **Add to access token** on. +4. In each client that calls the API, open **Client scopes** and add + `api-audience` as a **Default** scope. + +Every access token those clients obtain then carries the API in `aud`: + +```yaml +providers: + keycloak: + type: oidc + trust: idp + client_id: orders-api + discovery_url: https://sso.example.com/realms/main/.well-known/openid-configuration + audiences: [orders-api] +``` + +Linking the scope as **Optional** instead adds the audience only when the client +requests `scope=api-audience`. For the full credential and token lifecycle (password login plus SSO), see [login-and-sso](../auth/login-and-sso.md). For claim mapping and diff --git a/docs/v0.2/Reference/providers/overview.md b/docs/v0.2/Reference/providers/overview.md index 13afd644..f3c3605b 100644 --- a/docs/v0.2/Reference/providers/overview.md +++ b/docs/v0.2/Reference/providers/overview.md @@ -1,13 +1,12 @@ --- title: "Providers Overview" -description: The top-level providers section declares external identity providers (OIDC, SAML) that auth entities attach to through auth.bindings. +description: The top-level providers section declares external identity providers (OIDC, SAML) that auth entities sign users in through with auth.via. --- The top-level `providers:` section declares external identity providers (OIDC and -SAML) that auth entities can attach to. A declared provider does nothing on its own: -it becomes active only once an auth entity references it from `auth.bindings`. The -built-in `password` and `apiKey` providers are not declared here; they are selected -with `auth.via`. +SAML). A declared provider does nothing on its own: it becomes active once an auth +entity names it in `auth.via`. The built-in `password` and `apiKey` providers are +not declared here; `auth.via` names them directly. ## Structure @@ -24,8 +23,8 @@ providers: sp_entity_id: ${APP_URL} ``` -Each key under `providers:` is the provider name. That name is what an auth entity -references in a binding: +Each key under `providers:` is the provider name. An auth entity signs users in +through it by naming it in `auth.via`: ```yaml entities: @@ -33,43 +32,50 @@ entities: type: auth auth: identifier: email - via: password # built-in providers go in `via` - bindings: # external providers attach here - - provider: myoidc # references providers.myoidc - identifier: email + via: + - provider: password + - provider: myoidc + claim_mapping: + name: name + auto_provision: true + fields: + id: { type: id } + email: { type: string, required: true, unique: true } + name: { type: string } ``` -`auth.via` selects built-in providers (`password`, `apiKey`). External OIDC/SAML -providers attach through `auth.bindings`, where each entry carries the provider -name plus its own `identifier`, optional `claim_mapping`, and `auto_provision` -(defaults to true). See [Claim mapping](../auth/claim-mapping.md) for the binding -fields and [Login, SSO, and the token flow](../auth/login-and-sso.md) for the -runtime flow. +`auth.via` takes a provider name, a list of names, or a list of binding objects. +A name (`via: [password, myoidc]`) uses the entity's `auth.identifier` and the +binding defaults. A binding object carries the provider name plus its own +`identifier`, `claim_mapping`, `auto_provision` (default `true`) and `relations`. +See [Claim mapping](../auth/claim-mapping.md) for the binding fields and +[Login, SSO, and the token flow](../auth/login-and-sso.md) for the runtime flow. + +A `via` entry naming neither a built-in provider nor a key of `providers` is +rejected with `AUTH_VIA_UNKNOWN_PROVIDER`. ## Provider types | Type | Description | |------|-------------| -| `oidc` | OpenID Connect. The backend reads the provider's `discovery_url` at request time to find the authorization, token, and userinfo endpoints. | +| `oidc` | OpenID Connect. The backend reads the provider's `discovery_url` to find the authorization, token, userinfo and JWKS endpoints. | | `saml` | SAML 2.0 service provider. The backend fetches the IdP metadata at request time. | ## Token trust -A provider's `trust` decides who verifies its tokens on each request: +An OIDC provider's `trust` decides who verifies its tokens on each request: | `trust` | Meaning | |---------|---------| -| `app` (default) | After login the app mints and verifies its **own** session token. The IdP is used only during login; from then on the client carries the app's JWT. This is the current end-to-end behavior. | -| `idp` | The client keeps carrying the **IdP's** token and the app verifies it against the IdP on every call, issuing no session of its own. Only `discovery_url` is required; `issuer` is derived from the discovery document automatically. | +| `app` (default) | The IdP is used only during login. The app then mints its own session token, and the client carries that token. | +| `idp` | The client keeps carrying the **IdP's** access token and the app verifies it against the IdP on every call, issuing no session of its own. | -`trust: idp` is only valid for `oidc`/`saml` providers, and rejects the -app-signing fields (`algorithm`, `secret`, `ttl`, `refresh`, `delivery`) that only -make sense under `app` trust. +A SAML provider always signs users in through the app. -When both an `app` provider and an `idp` provider exist, the generated auth -dependency dispatches strictly on the bearer token's `iss` claim -- the app -issuer is verified with the app's own key, each IdP issuer against that IdP, and -anything else is rejected (no "try every verifier"): +When both an app-trust login and an `idp` provider exist, the generated auth +dependency dispatches strictly on the bearer token's `iss` claim: the app issuer +is verified with the app's own key, each IdP issuer against that IdP, and anything +else is rejected (no "try every verifier"): ```mermaid sequenceDiagram @@ -79,7 +85,7 @@ sequenceDiagram C->>A: request + Authorization: Bearer A->>A: read iss (unverified) alt iss == app issuer - A->>A: verify with app key (HS256) + A->>A: verify with app key else iss == A->>I: verify against issuer/audience I-->>A: ok / reject @@ -89,11 +95,10 @@ sequenceDiagram A-->>C: response (principal resolved) or 401 ``` -## Required fields per type +## Fields per type -Each provider config is closed (`additionalProperties: false`): only the fields -below are accepted, and unknown keys fail schema validation. Which fields are -required depends on `trust`. +Each provider config is closed: only the fields below are accepted, and any other +key fails with `SCHEMA_VALIDATION_ERROR`. ### OIDC @@ -102,34 +107,31 @@ required depends on `trust`. | `type` | yes | Must be `oidc`. | | `trust` | no | `app` (default) or `idp`. | | `client_id` | yes | OAuth client identifier issued by the IdP (e.g. "Client ID" in Google console, "Application (client) ID" in Azure). | -| `client_secret` | no | OAuth client secret. Omit it for a public client -- the token exchange runs without a `client_secret`. For confidential clients, its presence emits a `_CLIENT_SECRET` env var read at run time via a `SecretStr` setting; the spec value is only a placeholder, never the literal secret. | -| `discovery_url` | yes | OpenID configuration URL (`.../.well-known/openid-configuration`); used to resolve endpoints, JWKS keys, and the expected token issuer. | -| `issuer` | no | Override for the expected token `iss`. Normally not needed — the value is read from the discovery document automatically. | -| `audience` | no | Override for the expected token `aud`. Defaults to `client_id`, which is correct for ID tokens on Google, Azure, and standard Keycloak. Set this only when the token's `aud` differs from `client_id` — for example, Azure access tokens scoped to a separate API registration (`api://...` or a Microsoft Graph scope), or when you configure a Keycloak audience mapper to add a custom audience. | +| `discovery_url` | yes | OpenID configuration URL (`.../.well-known/openid-configuration`); used to resolve endpoints, JWKS keys and the expected token issuer. | +| `client_secret` | no, `trust: app` only | OAuth client secret. Omit it for a public client. For a confidential client the generated backend reads it at run time from the `_CLIENT_SECRET` env var, seeded with the spec value. | +| `issuer` | no, `trust: idp` only | Override for the expected token `iss`; read from the discovery document by default. | +| `audiences` | no, `trust: idp` only | The accepted token `aud` values. Defaults to `[client_id]`, and generation then warns with `TRUST_IDP_AUDIENCE_DEFAULTED`. For Keycloak, see [Keycloak: putting the API in `aud`](./oidc.md#keycloak-putting-the-api-in-aud). | + +`client_secret` under `trust: idp`, and `issuer` or `audiences` under +`trust: app`, fail with `SCHEMA_VALIDATION_ERROR`. There is no built-in discovery for any provider name. Names such as `google`, -`microsoft`, or `apple` are not special; you must supply `discovery_url` for every -`trust: app` OIDC provider. +`microsoft`, or `apple` are not special; supply `discovery_url` for every OIDC +provider. ### SAML | Field | Required | Description | |-------|----------|-------------| | `type` | yes | Must be `saml`. | -| `trust` | no | `app` (default) or `idp`. | -| `idp_metadata_url` | yes under `trust: app` | IdP metadata URL (fetched at request time). | -| `sp_entity_id` | yes under `trust: app` | This service provider's entity ID. | -| `issuer` | no | Expected `iss` of the IdP's tokens. | -| `audience` | no | Expected `aud` of the IdP's tokens. | +| `idp_metadata_url` | yes | IdP metadata URL (fetched at request time). | +| `sp_entity_id` | yes | This service provider's entity ID. | ## Environment interpolation Any value may use `${VAR}` interpolation, resolved at build time before schema -validation. This keeps secrets out of the *spec file*. Most resolved values are baked -into the built artifact, but `client_secret` is the exception: it is deferred to -runtime through a `_CLIENT_SECRET` env var backed by a pydantic `SecretStr` -setting, never baked as a literal. A `${VAR:-default}` form supplies a fallback when -the variable is unset: +validation. A `${VAR:-default}` form supplies a fallback when the variable is +unset: ```yaml providers: @@ -141,50 +143,45 @@ providers: ``` A referenced variable that has no value and no default aborts generation with -`INTERPOLATION_MISSING_VAR`. The literal `client_secret` must never appear in the -spec: the `${OIDC_CLIENT_SECRET}` placeholder above only marks the field -confidential, and the deployed backend reads the real value from its own -`_CLIENT_SECRET` runtime env var. +`INTERPOLATION_MISSING_VAR`. Keep the literal client secret out of the spec file; +the deployed backend reads it from its own `_CLIENT_SECRET` env var. See [Environment variables](../configuration/environment-variables.md). -## Routes a bound provider adds +## Routes a provider adds -Once an auth entity binds an OIDC provider, the generated backend exposes these +Once an auth entity signs users in through a provider, the backend exposes these routes under the entity's collection (the full path is `{api.basePath}[/{api.version}]/{collection}/...`). With -`api: { basePath: /api, version: v1 }` and a `users` collection bound to a -`myoidc` provider: +`api: { basePath: /api, version: v1 }` and a `users` collection: | Method and path | Purpose | |-----------------|---------| -| `GET /api/v1/users/auth/providers` | Lists the external providers bound to this collection (consumed by the generated login page). | -| `GET /api/v1/users/auth/myoidc` | Starts the login: 302 redirect to the IdP. | -| `GET /api/v1/users/auth/myoidc/callback` | Completes the IdP exchange: maps claims, finds or provisions the user, mints a one-time login code, and redirects to `{FRONTEND_URL}/auth/callback?code=`. | -| `POST /api/v1/auth/exchange` | Redeems the one-time login code (`{"code": "..."}`) for the app JWT (`{"token": ""}`). Emitted once whenever any collection binds an OIDC provider. | -| `GET /api/v1/users/auth/myoidc/logout` | RP-initiated logout: 302s to the provider's `end_session_endpoint` (with `client_id` and `post_logout_redirect_uri`) to end the IdP's SSO session. | - -These routes exist only when an entity actually binds the provider; a declared but -unbound provider emits nothing. - -Both `stackform build` and the `stackform run` dev server mount these OIDC routes. -SAML routes are currently only mounted by `stackform build`; SAML SSO under the dev -server is not yet implemented. - -The runtime compatibility suite drives this entire OIDC path against a live -loopback provider over HTTP: discovery, authorization redirect, `state`, PKCE -S256, DPoP token exchange, DPoP userinfo, automatic provisioning, one-time-code -redemption, and app-session issuance. Run it with -`pytest tests/integration/test_live_auth_flows.py`. +| `GET /api/v1/users/auth/providers` | Lists the external providers of this collection (consumed by the generated login page). | +| `GET /api/v1/users/auth/myoidc` | Starts the OIDC login: 302 redirect to the IdP. | +| `GET /api/v1/users/auth/myoidc/callback` | Completes the OIDC exchange: maps claims, finds or provisions the user, mints a one-time login code, and redirects to `{FRONTEND_URL}/auth/callback?code=`. | +| `GET /api/v1/users/auth/myoidc/logout` | RP-initiated logout: redirects to the provider's `end_session_endpoint`. | +| `GET /api/v1/users/auth/mysaml` | Starts the SAML login: redirect to the IdP. | +| `POST /api/v1/users/auth/mysaml/callback` | SAML Assertion Consumer Service: validates the response, then 303-redirects to `{FRONTEND_URL}/auth/callback?code=`. | +| `GET /api/v1/users/auth/mysaml/metadata` | SP metadata XML. | +| `GET /api/v1/users/auth/mysaml/logout` | Redirects to the IdP's single logout service. | +| `POST /api/v1/auth/exchange` | Redeems the one-time login code (`{"code": "..."}`) for the app JWT (`{"token": ""}`). | + +A `trust: idp` OIDC provider adds only the logout route, since it signs no one in +through the app. A declared provider that no entity names emits nothing. + +The `stackform run` development server and the generated FastAPI backend serve +these routes. The `drf` and `raw` targets and `stackform run --raw` reject a +provider that signs users in through the app with `BACKEND_FEATURE_UNSUPPORTED`. ### Logout Clearing the app JWT only ends the *app* session; the IdP keeps its own SSO cookie, so the next login is silent. The logout route ends the IdP session: the generated frontend's logout clears the token and, for an SSO session, navigates to -`/{collection}/auth/{provider}/logout`, which redirects to the IdP's -`end_session_endpoint`. Because the app mints its own token from `userinfo` and does -not retain the IdP `id_token`, logout is requested with `client_id` + -`post_logout_redirect_uri` (supported by Keycloak 18+) rather than `id_token_hint`. +`/{collection}/auth/{provider}/logout`, which redirects to the IdP. Because the app +mints its own token and does not retain the IdP `id_token`, OIDC logout is requested +with `client_id` + `post_logout_redirect_uri` (supported by Keycloak 18+) rather +than `id_token_hint`. ## See also diff --git a/docs/v0.2/Reference/providers/saml.md b/docs/v0.2/Reference/providers/saml.md index 243f9eb8..345683c9 100644 --- a/docs/v0.2/Reference/providers/saml.md +++ b/docs/v0.2/Reference/providers/saml.md @@ -5,7 +5,7 @@ description: SAML 2.0 Service Provider configuration for enterprise SSO. SAML 2.0 Service Provider configuration for enterprise SSO. A SAML provider is declared once under `providers:` and becomes active only when an identity entity -binds to it through `auth.bindings`. +names it in `auth.via`. ## Schema @@ -45,10 +45,9 @@ providers: ## Binding it to an entity -A provider does nothing until an identity entity binds to it. Attach SAML through -`auth.bindings` (not `auth.via`, which is reserved for the built-in `password` and -`apiKey` providers). Put `identifier`, `claim_mapping`, and `auto_provision` inside -the binding. +A provider does nothing until an identity entity names it in `auth.via`. Put +`identifier`, `claim_mapping`, `auto_provision` and `relations` inside the binding +object. ```yaml api: @@ -66,7 +65,7 @@ entities: type: auth auth: identifier: email - bindings: + via: - provider: mysaml identifier: email claim_mapping: @@ -77,6 +76,9 @@ entities: id: { type: id } email: { type: string, required: true } name: { type: string } + +repositories: + users: { entity: user } ``` SAML claim names are typically URNs or URLs. Use `claim_mapping` to map them to @@ -85,50 +87,40 @@ entity fields; only the keys you list are copied. See ## Generated routes -For each entity that binds a SAML provider, the engine generates three routes. They -are mounted under the API prefix (`api.basePath` plus the optional `api.version`), -so with the `api:` block above (`basePath: /api`, `version: v1`) and an entity -collection `user` bound to provider `mysaml`: +For each identity collection that signs users in through a SAML provider, the +`stackform run` development server and the generated FastAPI backend serve four +routes under the API prefix (`api.basePath` plus the optional `api.version`). +With the spec above: | Method | Path | Description | |--------|------|-------------| -| GET | `/api/v1/user/auth/mysaml` | Initiate SAML authentication (redirect to IdP). | -| POST | `/api/v1/user/auth/mysaml/callback` | Assertion Consumer Service (ACS); the IdP posts the `SAMLResponse` form field here. | -| GET | `/api/v1/user/auth/mysaml/metadata` | SP metadata XML; register this with your IdP. | +| GET | `/api/v1/users/auth/mysaml` | Initiate SAML authentication (redirect to the IdP). | +| POST | `/api/v1/users/auth/mysaml/callback` | Assertion Consumer Service (ACS); the IdP posts the `SAMLResponse` form field here. | +| GET | `/api/v1/users/auth/mysaml/metadata` | SP metadata XML; register this with your IdP. | +| GET | `/api/v1/users/auth/mysaml/logout` | Redirect to the IdP's single logout service. | The path segment after the prefix is `{collection}/auth/{provider}`, where -`{collection}` is the identity entity's collection name and `{provider}` is the -provider key under `providers:`. Register the ACS URL (the `/callback` route) and -the SP entity ID with your Identity Provider; the metadata endpoint emits a ready -descriptor you can hand to the IdP. - -If you omit `api.version`, the prefix is just `basePath`, so the routes become -`/api/user/auth/mysaml`, `/api/user/auth/mysaml/callback`, and -`/api/user/auth/mysaml/metadata`. - -## Callback response - -On a successful assertion the ACS endpoint maps the SAML attributes through -`claim_mapping`, falls back to the assertion `NameID` for the `identifier` field -when no claim supplied it, finds or provisions the user, and returns the -application's own JWT plus the sanitized record as JSON: - -```json -{ - "token": "", - "user": { "id": "...", "email": "user@example.com", "name": "..." } -} -``` - -The returned `user` has `_id` and `_auth` stripped. The `token` is your app's JWT, -not the IdP assertion; send it back as `Authorization: Bearer ` on -subsequent requests. +`{collection}` is the identity repository name and `{provider}` is the provider +key under `providers:`. Register the ACS URL (the `/callback` route) and the SP +entity ID with your Identity Provider; the metadata endpoint emits a ready +descriptor you can hand to the IdP. The `drf` and `raw` targets and +`stackform run --raw` reject a SAML provider with `BACKEND_FEATURE_UNSUPPORTED`. + +## Callback + +The ACS endpoint accepts only a response to a login this app started, and each +assertion only once. It maps the SAML attributes through `claim_mapping`, falls +back to the assertion `NameID` for the `identifier` field when no claim supplied +it, finds or provisions the user, and 303-redirects the browser to +`{FRONTEND_URL}/auth/callback?code=`. The frontend redeems the one-time +code at `POST {prefix}/auth/exchange` for the app JWT, exactly as after an +[OIDC login](./oidc.md#login-and-callback-flow). ## Auto-provisioning `auto_provision` defaults to `true`. When an authenticated SAML user has no matching -record, the backend creates one (assigning a new `uuid4` id) and logs them in. Set -`auto_provision: false` to require pre-existing accounts; an unknown user then gets: +record, the backend creates one and signs them in. Set `auto_provision: false` to +require pre-existing accounts; an unknown user then gets: ``` 403 {"detail": "User not found and auto-provisioning is disabled"} @@ -138,9 +130,11 @@ record, the backend creates one (assigning a new `uuid4` id) and logs them in. S | Status | When | |--------|------| -| 401 | `{"detail": "SAML error: "}` when the assertion fails validation. | -| 401 | `{"detail": "SAML authentication failed"}` when the response is processed but not authenticated. | -| 403 | `{"detail": "User not found and auto-provisioning is disabled"}` when the user is unknown and `auto_provision` is off. | +| 400 | `Missing SAMLResponse` or `Malformed SAMLResponse`. | +| 401 | `SAML authentication failed: ` when the assertion fails validation. | +| 401 | `SAML response answers no pending login` or `SAML assertion already consumed`. | +| 401 | The claims carry no usable identifier, or signing in would duplicate a unique value. | +| 403 | `User not found and auto-provisioning is disabled`. | See also [Login, SSO, and the token flow](../auth/login-and-sso.md) for how the React login page lists providers and how the token flow ties into the rest of auth. diff --git a/docs/v0.2/Reference/repositories/composable-crud.md b/docs/v0.2/Reference/repositories/composable-crud.md index f832bf25..aec8d1dc 100644 --- a/docs/v0.2/Reference/repositories/composable-crud.md +++ b/docs/v0.2/Reference/repositories/composable-crud.md @@ -126,7 +126,7 @@ The remaining operations behave normally: `POST /api/v1/my-collection`, | Code | Cause | |------|-------| -| `CRUD_OPERATION_INVALID` | A `crud:` entry is not one of `list`, `get`, `create`, `update`, `replace`, `delete`. | +| `SCHEMA_VALIDATION_ERROR` | A `crud:` entry is not one of `list`, `get`, `create`, `update`, `replace`, `delete`. | | `CRUD_WITHOUT_ENTITY` | A repository declares `crud:` but no `entity:`. | | `REPOSITORY_EMPTY` | A repository has neither an `entity:` nor any custom `operations:`. | diff --git a/docs/v0.2/Reference/repositories/lifecycle.md b/docs/v0.2/Reference/repositories/lifecycle.md new file mode 100644 index 00000000..12ff05ab --- /dev/null +++ b/docs/v0.2/Reference/repositories/lifecycle.md @@ -0,0 +1,87 @@ +--- +title: "Record Lifecycle" +description: "softDelete keeps deleted rows as tombstones, ttl expires rows a fixed time after a timestamp, and includeDeleted lets a custom query read both." +--- + +A repository can control how long its rows stay visible: + +- `softDelete` turns DELETE into a tombstone: the row is kept and stamped, not erased. +- `ttl` expires a row a fixed number of seconds after a timestamp it holds. + +Both need an `entity`. They work wherever the repository's datastore is served: + +| Datastore | Backends | +|-----------|----------| +| MongoDB | dev server, FastAPI, raw ASGI, DRF | +| Relational | dev server, FastAPI | + +```yaml +repositories: + sessions: + entity: session + softDelete: removedAt + ttl: { field: lastSeenAt, seconds: 86400 } +``` + +## `softDelete` + +Type: string (field name). Optional. + +DELETE sets this field to the current time instead of removing the row, and answers `200` with the stamped record. From then on the row is hidden from `list`, `get`, `update`, `replace`, `delete` and from custom operations, so a second DELETE answers `404`. A removed row cannot be restored. + +The field must be a `datetime`. When the entity does not declare it, it is added as a readonly `datetime`; either way a request body that writes it is rejected with `422`. + +Custom operations that delete stamp the field as well: + +- MongoDB: `deleteOne`, `deleteMany` and `findOneAndDelete`, also inside `bulkWrite`. +- Relational: a `DELETE` statement on the table, including one that selects its rows through a subquery or `USING`. + +A unique value of a removed row is free again: a new row may take it. Unique indexes cover only rows that are not removed. + +On an identity collection, a removed user can no longer sign in, and a token issued to it before the removal is refused with `401`. Its email can be registered again. A sign-in through an external provider never revives a removed user; it provisions a new one. + +A record's files stay in storage when the record is soft-deleted, since the record is kept. See [file endpoints](../../../v0.3/file-endpoints.md#lifecycle). + +## `ttl` + +Type: object with `field` (a `datetime` field of the entity) and `seconds` (integer, at least 1). Optional. + +A row expires `seconds` after the instant stored in `field`. Expired rows are excluded from every query at once; a row whose `field` is null never expires. The server erases expired rows at startup and every 60 seconds, deleting the files they hold. On MongoDB, a TTL index named `lifecycle_expiry` on `field` also erases them, 120 seconds after the purge would. + +The generated UI lists and opens only the rows the API serves, so it never shows a removed or expired row. + +## Custom operations + +Custom operations keep to the lifecycle of every collection or table they touch, not only their own: + +- MongoDB: reads and writes match visible rows only, and aggregation pipelines see visible rows of the collections they join through `$lookup`, `$unionWith`, `$graphLookup` and `$facet`. `bulkWrite` applies the same rule to each of its requests, and `estimatedDocumentCount` answers the exact count of visible rows. +- Relational: every table a `sql` statement names, in joins, subqueries and `UNION`s alike, stands for its visible rows; `UPDATE` and `DELETE` reach visible rows only. + +Operations inherited from an abstract repository follow the lifecycle of the repository that inherits them. + +## `includeDeleted` + +Type: `true`. Allowed only on a custom `query` operation of a repository with `softDelete` or `ttl`. + +The operation's reads also see soft-deleted and expired rows. Its writes stay confined to visible rows: `includeDeleted` cannot update, delete or restore a removed or expired row. Generated `list`, `get`, `update`, `replace` and `delete` operations always hide them. + +```yaml +repositories: + sessions: + entity: session + softDelete: removedAt + operations: + history: + query: + sql: "SELECT * FROM sessions WHERE owner = :owner" + params: { owner: "$principal.sub" } + includeDeleted: true +``` + +## Errors + +| Code | Cause | +|------|-------| +| `LIFECYCLE_FIELD_NOT_FOUND` | `ttl.field` is not a field of the entity. | +| `LIFECYCLE_FIELD_NOT_DATETIME` | The `softDelete` or `ttl.field` field is not a `datetime`. | +| `SCHEMA_VALIDATION_ERROR` | `softDelete` or `ttl` without `entity`, or `includeDeleted` on a repository without a lifecycle. | diff --git a/docs/v0.2/Reference/repositories/meta.json b/docs/v0.2/Reference/repositories/meta.json index 578e3b7d..d8026739 100644 --- a/docs/v0.2/Reference/repositories/meta.json +++ b/docs/v0.2/Reference/repositories/meta.json @@ -3,6 +3,7 @@ "pages": [ "overview", "composable-crud", + "lifecycle", "operations" ] } diff --git a/docs/v0.2/Reference/repositories/operations.md b/docs/v0.2/Reference/repositories/operations.md index 699efac1..e5204d73 100644 --- a/docs/v0.2/Reference/repositories/operations.md +++ b/docs/v0.2/Reference/repositories/operations.md @@ -6,7 +6,7 @@ description: "How a repository selects its CRUD subset with crud:, and how custo A repository exposes two kinds of operations: - The six built-in CRUD operations, selected with the `crud:` list. -- Custom operations under `operations:`, each backed by a MongoDB `query:` command or a `handler:` import path. +- Custom operations under `operations:`, each backed by a `query:` (a MongoDB command or a SQL statement) or a `handler:` import path. ## Built-in CRUD operations @@ -39,7 +39,7 @@ repositories: crud: [create, list, get, delete] # no update -> no PATCH route, no Edit action ``` -Operation names must be among `list`, `get`, `create`, `update`, `replace`, `delete`. An unknown name is reported as `CRUD_OPERATION_INVALID`. A repository that sets `crud:` but declares no `entity:` is reported as `CRUD_WITHOUT_ENTITY`. +Operation names must be among `list`, `get`, `create`, `update`, `replace`, `delete`. Any other name fails schema validation (`SCHEMA_VALIDATION_ERROR`). A repository that sets `crud:` but declares no `entity:` is reported as `CRUD_WITHOUT_ENTITY`. ## Disabling `replace` (or any op) globally @@ -78,11 +78,11 @@ services: ## Custom operations -Custom operations are declared under `repositories..operations`. Each entry needs either a `query:` (a MongoDB command) or a `handler:` (a dotted import path). An entry with neither is reported as `OPERATION_MISSING_QUERY_OR_HANDLER`. +Custom operations are declared under `repositories..operations`. An operation name starts with a letter and contains only letters, digits and underscores; it becomes a method of the generated repository and service classes. Each entry declares exactly one of `query:` or `handler:` (a dotted import path); neither or both fails schema validation. ### Query-backed operations -A `query:` runs a named MongoDB command against the repository's collection. The command must be one of the supported names, and most commands require a specific argument field: +On a MongoDB datastore, a `query:` runs a named MongoDB command against the repository's collection. The command must be one of the supported names, and most commands require a specific argument field: ```yaml repositories: @@ -93,7 +93,7 @@ repositories: query: command: find filter: { status: published } - count-all: + countAll: query: command: countDocuments filter: {} @@ -110,34 +110,83 @@ Supported commands and their required field: | `requests` | `bulkWrite` | | (none) | `estimatedDocumentCount` | +The other arguments are `update` (`updateOne`, `updateMany`, `findOneAndUpdate`), `replacement` (`replaceOne`, `findOneAndReplace`) and `key` (the field `distinct` returns). + An unknown command is reported as `QUERY_COMMAND_UNKNOWN`; a missing required field as `QUERY_MISSING_FIELD`. -By default the query targets the repository's entity collection. Override the target with `query.collection`: +By default the query targets the repository's own collection. `query.collection` points it at another repository instead: ```yaml repositories: myCollection: entity: myEntity operations: - recent-related: + recentRelated: query: command: find filter: {} - collection: my-other-collection # query a different collection + collection: myOtherCollection # another repository + myOtherCollection: + entity: myOtherEntity +``` + +`collection` must name a concrete repository; anything else is reported as `QUERY_COLLECTION_NOT_FOUND`. The query runs on that repository's datastore, and its form must suit that datastore (`QUERY_FORM_MISMATCH` otherwise). A repository with no `entity:` can still declare query operations, but each query must then supply a `collection:`. Omitting both is reported as `QUERY_MISSING_COLLECTION`. + +### SQL operations + +On a relational datastore, a `query:` is a `sql` statement with named parameters, bound from `params`: + +```yaml +repositories: + events: + entity: event + operations: + byKind: + query: + sql: "SELECT * FROM events WHERE kind = :kind ORDER BY at DESC" + params: { kind: "$kind" } ``` -A repository with no `entity:` can still declare query operations, but each query must then supply a `collection:`. Omitting both is reported as `QUERY_MISSING_COLLECTION`. +A `sql` query on a MongoDB datastore, or a `command` query on a PostgreSQL datastore, is reported as `QUERY_FORM_MISMATCH`; an empty `sql` as `QUERY_SQL_INVALID`. + +### Request values + +A query value that is a string starting with `$` is replaced by a request value when the operation runs; every other value is used as written: + +| Reference | Value | +|-----------|-------| +| `$` or `$params.` | The route's path parameter (or service input) ``. | +| `$principal.sub` | The authenticated principal's subject. | +| `$principal.identity_collection`, `$principal.roles`, `$principal.record.` | The principal's identity, roles and stored record. | +| `$body.` | A field of the request body. | + +A reference that does not resolve becomes `null`. + +```yaml +repositories: + posts: + entity: post + operations: + mine: + query: + command: find + filter: { ownerId: $principal.sub, status: $status } +``` + +### Lifecycle repositories + +On a repository with `softDelete` or `ttl`, query operations hide removed and expired rows of every collection or table they touch, and their writes reach visible rows only. `includeDeleted: true` makes an operation's reads see them as well. See [Record lifecycle](lifecycle.md#custom-operations). ### Handler-backed operations -For logic that a single MongoDB command cannot express, point `handler:` at an async function. The generated repository imports it and calls it as `await handler(**kwargs)`, where the keyword arguments come from the caller: +For logic that a query cannot express, point `handler:` at a plain or async function instead of declaring a `query:`. It receives the same request object as a service handler and returns the response body; see [Handler context](../services/handler-context.md): ```yaml repositories: myCollection: entity: myEntity operations: - archive-stale: + archiveStale: handler: my_app.archive_stale ``` diff --git a/docs/v0.2/Reference/repositories/overview.md b/docs/v0.2/Reference/repositories/overview.md index a21e5401..6de85acd 100644 --- a/docs/v0.2/Reference/repositories/overview.md +++ b/docs/v0.2/Reference/repositories/overview.md @@ -1,11 +1,11 @@ --- title: "Repositories Overview" -description: Repositories bind an entity to a MongoDB collection, select which CRUD operations are available, and declare custom query and handler operations. +description: Repositories bind an entity to a collection or table in a datastore, select which CRUD operations are available, and declare custom query and handler operations. --- -Repositories bind an entity to a MongoDB collection, select which CRUD operations are available, and declare custom operations. +Repositories bind an entity to a collection (MongoDB) or table (relational datastores), select which CRUD operations are available, and declare custom operations. -The repository name is the MongoDB collection name. It is free-form (no pluralization is applied) and is what services reference. A repository whose `entity` is `myEntity` and whose key is `myCollection` stores documents in a collection named `myCollection`. +The repository name is the collection or table name. It is free-form (no pluralization is applied) and is what services reference. A repository whose `entity` is `myEntity` and whose key is `myCollection` stores documents in a collection named `myCollection`. ## Structure @@ -13,12 +13,15 @@ The repository name is the MongoDB collection name. It is free-form (no pluraliz repositories: myCollection: entity: myEntity - crud: [list, get, create, update, delete] # subset; omit for all six + datastore: main # optional, see datastores + crud: [list, get, create, update, delete] # subset; omit for all six + softDelete: removedAt # optional, see lifecycle + ttl: { field: lastSeenAt, seconds: 86400 } # optional, see lifecycle operations: # custom ops (optional) publishedByOwner: query: command: find - filter: { ownerId: $ownerId, status: 'published' } + filter: { ownerId: $ownerId, status: published } ``` ## Fields @@ -35,7 +38,7 @@ Type: list of operation names. Optional. Selects which built-in CRUD operations this repository exposes. When omitted, all six are enabled. Listing a subset opts the rest out end-to-end: an omitted operation produces no repository method, no service, no HTTP route, no OpenAPI entry, and no UI action. -Valid operation names are `list`, `get`, `create`, `update`, `replace`, `delete`. Any other value is rejected with `CRUD_OPERATION_INVALID`. Specifying `crud` without an `entity` is rejected with `CRUD_WITHOUT_ENTITY`. +Valid operation names are `list`, `get`, `create`, `update`, `replace`, `delete`; any other value fails schema validation. Specifying `crud` without an `entity` is rejected with `CRUD_WITHOUT_ENTITY`. For the full enablement model and resolution precedence, see [composable-crud](composable-crud.md). @@ -45,13 +48,62 @@ Type: string (datastore name). Optional. The datastore this repository is stored in, referencing a key in [`datastores:`](../configuration/datastores.md). Omit it to use the default datastore. This is how a single spec keeps different repositories in different databases. +### `softDelete` and `ttl` + +Optional. `softDelete: ` makes DELETE stamp a timestamp instead of erasing the row; `ttl: { field, seconds }` expires rows a fixed time after a timestamp. Both require `entity`. See [Record lifecycle](lifecycle.md). + +### `abstract` and `extends` + +Type: boolean / string (repository name). Optional. + +An `abstract: true` repository is a template: it stores nothing and no service may reference it. A repository with `extends: ` inherits every key of that abstract repository except `abstract` and `extends`; its own keys win and `operations` merge per operation, so it can add or redefine single operations. Chains of abstract repositories are allowed. + +```yaml +repositories: + published: + abstract: true + operations: + recent: + query: + command: find + filter: { status: published } + articles: + entity: article + extends: published + pages: + entity: page + extends: published + crud: [list, get] +``` + +When the abstract repository declares `operations`, the generated backend emits it as a base class that every extending repository subclasses. An abstract repository without operations only contributes configuration. + +An abstract repository may declare any repository key, including `crud`, `softDelete`, `ttl` and operations whose queries use `$params` symbols or name fields of the stored entity. Those keys are checked where they take effect: on every extending repository, whose merged entry must be a valid repository for its own entity. + +```yaml +repositories: + auditable: + abstract: true + crud: [list, get, create] + softDelete: deletedAt + ttl: { field: createdAt, seconds: 86400 } + operations: + bySlug: + query: + command: findOne + filter: { slug: $params.slug } + articles: + entity: article + extends: auditable +``` + ### `operations` Type: map. Optional. -Declares custom operations beyond the six built-in CRUD verbs. This block is exclusively for custom operations; it does NOT toggle CRUD on or off (use `crud:` for that). Each entry must define either a `query` or a `handler`; an entry with neither is rejected with `OPERATION_MISSING_QUERY_OR_HANDLER`. +Declares custom operations beyond the six built-in CRUD verbs. This block is exclusively for custom operations; it does NOT toggle CRUD on or off (use `crud:` for that). Operation names start with a letter and contain only letters, digits and underscores (`publishedByOwner`, `count_all`). Each entry defines exactly one of `query` or `handler`; an entry with neither or both fails schema validation. -A `query` runs a MongoDB command against the repository's collection: +On a MongoDB datastore a `query` runs a MongoDB command against the repository's collection: ```yaml repositories: @@ -61,15 +113,28 @@ repositories: recent: query: command: find - filter: { status: 'published' } - collection: myArchiveCollection # optional override of the target collection + filter: { status: published } + collection: myArchiveCollection # optional: another repository to query +``` + +On a relational datastore a `query` is a parameterized `sql` statement: + +```yaml +repositories: + events: + entity: event + operations: + mine: + query: + sql: "SELECT * FROM events WHERE owner = :owner" + params: { owner: "$principal.sub" } ``` -The `command` must be one of: `find`, `findOne`, `aggregate`, `countDocuments`, `estimatedDocumentCount`, `distinct`, `insertOne`, `insertMany`, `updateOne`, `updateMany`, `replaceOne`, `deleteOne`, `deleteMany`, `findOneAndUpdate`, `findOneAndReplace`, `findOneAndDelete`, `bulkWrite`. An unknown command is rejected with `QUERY_COMMAND_UNKNOWN`. Most commands require a specific field (`find`/`findOne`/`updateOne`/`deleteOne`/... require `filter`; `aggregate` requires `pipeline`; `insertOne` requires `document`; `insertMany` requires `documents`; `bulkWrite` requires `requests`); a missing required field is rejected with `QUERY_MISSING_FIELD`. +Query values that start with `$` are resolved per request; see [operations](operations.md#request-values). The `command` must be one of: `find`, `findOne`, `aggregate`, `countDocuments`, `estimatedDocumentCount`, `distinct`, `insertOne`, `insertMany`, `updateOne`, `updateMany`, `replaceOne`, `deleteOne`, `deleteMany`, `findOneAndUpdate`, `findOneAndReplace`, `findOneAndDelete`, `bulkWrite`. An unknown command is rejected with `QUERY_COMMAND_UNKNOWN`, and a command missing its required field (see [operations](operations.md#query-backed-operations)) with `QUERY_MISSING_FIELD`. -By default a query targets the repository's collection. If the repository declares no `entity`, the query must set `collection:` explicitly, otherwise the build fails with `QUERY_MISSING_COLLECTION`. +By default a query targets the repository's own collection. `collection:` must name another concrete repository, otherwise the build fails with `QUERY_COLLECTION_NOT_FOUND`. If the repository declares no `entity`, the query must set `collection:`, otherwise the build fails with `QUERY_MISSING_COLLECTION`. -A `handler` names a dotted import path to your own function instead of a Mongo command: +A `handler` names a dotted import path to your own function instead of a query: ```yaml repositories: @@ -147,11 +212,17 @@ Each repository name is independent of the entity name. `users` binds to entity | Code | Cause | |------|-------| -| `CRUD_WITHOUT_ENTITY` | `crud` specified but the repository has no `entity`. | -| `CRUD_OPERATION_INVALID` | A name in `crud` is not one of the six CRUD operations. | -| `OPERATION_MISSING_QUERY_OR_HANDLER` | A custom operation declares neither `query` nor `handler`. | +| `CRUD_WITHOUT_ENTITY` | `crud` specified on a concrete repository that has no `entity`. | | `QUERY_COMMAND_UNKNOWN` | `query.command` is not a recognized MongoDB command. | | `QUERY_MISSING_FIELD` | The command requires a field (e.g. `filter`, `pipeline`) that is absent. | | `QUERY_MISSING_COLLECTION` | A query on an entity-less repository omits `collection`. | +| `QUERY_COLLECTION_NOT_FOUND` | `query.collection` does not name a concrete repository. | +| `QUERY_FORM_MISMATCH` | A `sql` query on a MongoDB datastore, or a `command` query on a PostgreSQL datastore. | +| `QUERY_SQL_INVALID` | `query.sql` is empty. | | `REPOSITORY_EMPTY` | A repository declares neither `entity` nor any custom operation. | +| `REPOSITORY_EXTENDS_NOT_FOUND` | `extends` names a repository that does not exist. | +| `REPOSITORY_EXTENDS_NOT_ABSTRACT` | `extends` names a repository without `abstract: true`. | +| `REPOSITORY_CIRCULAR_EXTENDS` | An `extends` chain loops back on itself. | +| `DESUGAR_ABSTRACT_REPOSITORY_BOUND` | A service references an abstract repository. | | `DESUGAR_ABSTRACT_REFERENCED` | The `entity` names an abstract entity. | +| `LIFECYCLE_FIELD_NOT_FOUND`, `LIFECYCLE_FIELD_NOT_DATETIME` | See [Record lifecycle](lifecycle.md#errors). | diff --git a/docs/v0.2/Reference/roles/hierarchy.md b/docs/v0.2/Reference/roles/hierarchy.md index 673d37c4..3883d8ac 100644 --- a/docs/v0.2/Reference/roles/hierarchy.md +++ b/docs/v0.2/Reference/roles/hierarchy.md @@ -1,24 +1,13 @@ --- title: "Role Hierarchy" -description: Declares which roles subsume other roles. Parsed today but not yet applied during access enforcement; only the generated UI consumes it. +description: Declares which roles include other roles, so a rule for a lower role also admits the roles above it. --- -Role hierarchy lets a higher role subsume lower ones (for example `admin` including `moderator`). You declare it in the top-level `roles:` map, keyed by auth-entity name. - -> **Current status** -> -> Role hierarchy is parsed and validated, but it is NOT applied when the generated -> backend evaluates access rules. Today only exact role matches are enforced, so a -> principal whose role is `admin` does NOT automatically satisfy `access: user:moderator`. -> The one consumer of the hierarchy is the generated React UI, which uses it to build -> its client-side role matrix. To gate routes by role today, use `auth.roles: ` -> and explicit `access: entity:role` rules (see "What works today" below). +Role hierarchy lets a higher role include lower ones (for example `admin` including +`moderator`). You declare it in the `roles:` block of a `type: auth` entity. ## Syntax -The hierarchy lives in a top-level `roles:` map. Each top-level key is the name of a -`type: auth` entity; its value is a map of role to the list of roles that role includes. - ```yaml version: "0.2" @@ -28,58 +17,51 @@ entities: auth: identifier: email via: password - roles: role + roles: + values: [admin, moderator, user] + default: user + hierarchy: + admin: [moderator] + moderator: [user] fields: + id: { type: id } email: { type: string, required: true, unique: true } - role: - type: enum - values: [admin, moderator, user] - default: user - -roles: - user: - admin: [moderator] - moderator: [user] ``` -The `roles:` block is NOT placed inside the entity. It is a top-level section, and its -keys must name auth entities. A `roles:` key under an entity definition is silently -ignored by the v0.2 parser. +Each key of `hierarchy` is a role; its value lists the roles that role includes. ## Behavior -- **Parse time:** Each top-level key must name a declared `type: auth` entity, otherwise - `ROLES_UNKNOWN_ENTITY` is raised. The role names inside the map are NOT checked against - the enum field's `values`; unknown role names are accepted as-is. -- **Generation:** The hierarchy is flattened transitively and handed to the React UI - generator. Given `admin: [moderator]` and `moderator: [user]`, the UI sees: +- **Parse time:** every role named in `hierarchy`, as a key or in a list, must be one + of `values` (`ROLES_HIERARCHY_UNKNOWN_ROLE`), and the inclusions must not form a + cycle (`ROLES_HIERARCHY_CYCLE`). A `roles:` block is accepted only on a + `type: auth` entity. +- **Generation:** the hierarchy is flattened transitively. Given `admin: [moderator]` + and `moderator: [user]`: - `admin` -> `{moderator, user}` - `moderator` -> `{user}` -- **Enforcement:** The generated backend does NOT apply this expansion. Access rules - match the principal's role by exact string only. +- **Enforcement:** a principal holds its own roles plus every role they include. An + `admin` therefore satisfies `access: user:moderator` and `access: user:user`. The + same expansion decides which roles a caller may grant (see + [Roles overview](./overview.md#storage)). -> **Enforcement status** +> **Enforcement** > -> Roles (exact match), `:*` scoping, and ownership/ABAC `when` rules +> Roles, role hierarchies, `:*` scoping, and ownership/ABAC `when` rules > are enforced by the generated FastAPI backend, the generated DRF backend, and the -> `stackform run` dev server alike. A request that fails a rule gets 401 (missing or -> invalid credentials), 403 (authenticated but not allowed), or 404 (record not -> found, checked before ownership). Two current limitations: role hierarchy (for -> example `admin` inheriting `moderator`) is accepted in YAML but not yet applied -> during enforcement -- only exact role matches are checked; and conditional or -> ownership rules require a v0.2 spec, because v0.1 specs do not parse `when` rules. +> `stackform run` dev server alike. A request that no rule admits gets 401 when it +> carries no valid credentials and 403 otherwise; a missing record answers 404 +> before ownership is checked. ## Options | Field | Required | Type | Description | |-------|----------|------|-------------| -| `roles.` | no | map of role to list of roles | Each key is a role; the value lists the roles it includes. The entity name must be a declared `type: auth` entity. | +| `roles.hierarchy` | no | map of role to list of roles | Each key is a role; the value lists the roles it includes. | -## What works today +## Examples -Role checks are enforced through exact matching, with no inheritance. To make a role -gate work, declare the role as an `enum` field, point `auth.roles` at it, and write -explicit `access` rules per controller or operation. +### Gating by the lowest role that may act ```yaml version: "0.2" @@ -94,80 +76,91 @@ entities: auth: identifier: email via: password - roles: role + roles: + values: [admin, moderator, user] + default: user + hierarchy: + admin: [moderator] + moderator: [user] fields: + id: { type: id } email: { type: string, required: true, unique: true } - role: - type: enum - values: [admin, moderator, user] - default: user - -controllers: - myCollection: - access: user:admin # only principals whose role field equals "admin" -``` - -With this spec, `DELETE /api/v1/my-collection/{id}` requires a `user` principal whose `role` -field is exactly `admin`. A `moderator` is rejected with 403, even if you declared a -hierarchy: hierarchy does not grant the `admin` role to a `moderator`. - -The access value vocabulary that role gating relies on: - -- `entity:role` -- principal of that auth entity whose role field equals `role` (exact). -- `entity:*` -- any authenticated principal of that auth entity, regardless of role. -- A bare `role` -- shorthand for `entity:role` when exactly one auth entity exists. - With two or more auth entities, a bare role is `ACCESS_AMBIGUOUS_ROLE`; with none, - it is `ACCESS_NO_AUTH_ENTITY`. -The principal's role is read at request time from the record's `auth.roles` enum field -(falling back to a `roles` or `role` field if `auth.roles` is unset). Role changes in -MongoDB take effect on the next request; the JWT does not carry roles. + post: + fields: + id: { type: id } + title: { type: string, required: true } -## Examples +repositories: + users: + entity: user + posts: + entity: post -### Declaring a hierarchy (UI-only today) +services: + posts: + repository: posts -```yaml -roles: - user: - admin: [moderator] - moderator: [user] +controllers: + posts: + service: posts + operations: + list: { access: public } + get: { access: public } + create: { access: user:user } + update: { access: user:moderator } + delete: { access: user:admin } ``` -- The generated React UI treats `admin` as including `{moderator, user}` for its - client-side role matrix. -- The generated backend still matches `access: user:moderator` only against principals - whose role field equals `moderator`. An `admin` principal is NOT auto-matched. +A `moderator` can create and update posts but not delete them; an `admin` can do +all three. Each rule names the lowest role allowed, and the hierarchy admits every +role above it. ### Multiple auth entities ```yaml -roles: +entities: user: - admin: [moderator] - moderator: [user] + type: auth + auth: { identifier: email, via: password } + roles: + values: [admin, moderator, user] + default: user + hierarchy: + admin: [moderator] + moderator: [user] + fields: + id: { type: id } + email: { type: string, required: true, unique: true } apiClient: - enterprise: [premium] - premium: [free] + type: auth + auth: { identifier: clientId, via: apiKey } + roles: + values: [enterprise, premium, free] + default: free + hierarchy: + enterprise: [premium] + premium: [free] + fields: + id: { type: id } + clientId: { type: string, required: true, unique: true } + apiKey: { type: string, required: true } ``` -Each top-level key names a separate auth entity. Hierarchies are scoped per entity; -`user` roles and `apiClient` roles are independent. There is no mechanism to declare -that one entity's role includes another entity's role. +Hierarchies are scoped per entity: `user` roles and `apiClient` roles are +independent, and one entity's role never includes another entity's role. ### No hierarchy -Omit the `roles:` block entirely (or list no inclusions). Roles then match only -themselves -- which, today, is the only behavior the backend enforces regardless. +Omit `hierarchy`. Each role then matches only itself. ## Errors | Condition | Error code | Message | |-----------|-----------|---------| -| Top-level `roles:` key is not a declared auth entity | `ROLES_UNKNOWN_ENTITY` | Role hierarchy references unknown auth entity '' | - -The role names listed inside the map are not validated against the enum field's -`values`, so a typo in a role name produces no error -- it simply never matches. +| `hierarchy` names a role missing from `values` | `ROLES_HIERARCHY_UNKNOWN_ROLE` | Role hierarchy references undeclared role '' | +| `hierarchy` is cyclic | `ROLES_HIERARCHY_CYCLE` | Role hierarchy for '' is cyclic through '' | +| `roles:` on an entity that is not `type: auth` | `SCHEMA_VALIDATION_ERROR` | | ## See Also diff --git a/docs/v0.2/Reference/roles/overview.md b/docs/v0.2/Reference/roles/overview.md index 8c255707..cd76ddac 100644 --- a/docs/v0.2/Reference/roles/overview.md +++ b/docs/v0.2/Reference/roles/overview.md @@ -1,34 +1,58 @@ --- title: "Roles Overview" -description: Declare per-user roles with an enum field and wire them to authorization via auth.roles. +description: Declare per-user roles on an auth entity, either as managed roles or as an enum field named by auth.roles, and use them in access rules. --- -A role is an enum field on a `type: auth` entity, pointed at by `auth.roles`. The -field's value is read on every request and used to match role-scoped access rules -such as `access: user:admin`. +A role belongs to a record of a `type: auth` entity. It is read on every request and +matched by role-scoped access rules such as `access: user:admin`. An entity keeps its +roles in one of two ways: -> **Enforcement status** +- **Managed roles:** an entity-level `roles:` block. The roles live in the record's + internal `_auth` namespace, are not part of the API's fields, and are never written + through the API. +- **A role field:** `auth.roles` names an `enum` field. The role is an ordinary field, + returned in responses and set through the API within the limits below. + +> **Enforcement** > -> Roles (exact match), `:*` scoping, and ownership/ABAC `when` rules +> Roles, role hierarchies, `:*` scoping, and ownership/ABAC `when` rules > are enforced by the generated FastAPI backend, the generated DRF backend, and the -> `stackform run` dev server alike. A request that fails a rule gets 401 (missing or -> invalid credentials), 403 (authenticated but not allowed), or 404 (record not -> found, checked before ownership). Two current limitations: role hierarchy (for -> example `admin` inheriting `moderator`) is accepted in YAML but not yet applied -> during enforcement -- only exact role matches are checked; and conditional or -> ownership rules require a v0.2 spec, because v0.1 specs do not parse `when` rules. +> `stackform run` dev server alike. A request that no rule admits gets 401 when it +> carries no valid credentials and 403 otherwise; a missing record answers 404 +> before ownership is checked. ## Syntax -Declare an `enum` field with the allowed role names, then name that field in -`auth.roles`: +### Managed roles ```yaml version: "0.2" -api: - basePath: /api - version: v1 +entities: + user: + type: auth + auth: + identifier: email + via: password + roles: + values: [admin, moderator, user] + default: user + hierarchy: + admin: [moderator] + fields: + id: { type: id } + email: { type: string, required: true, unique: true } + displayName: { type: string, required: true } + +repositories: + users: + entity: user +``` + +### A role field + +```yaml +version: "0.2" entities: user: @@ -38,68 +62,85 @@ entities: via: password roles: role fields: + id: { type: id } email: { type: string, required: true, unique: true } displayName: { type: string, required: true } role: type: enum values: [admin, moderator, user] default: user + +repositories: + users: + entity: user ``` -Roles ARE a field. The role lives in a normal enum field on the entity; `auth.roles` -is the pointer that tells the authorizer which field holds the role. There is no -separate entity-level `roles:` block (see [Hierarchy and current limits](#hierarchy-and-current-limits)). +An entity may declare both: the roles then live in the `auth.roles` field, and the +`roles:` block adds a hierarchy and the default role. ## Behavior -- **Parse time:** `auth.roles` must name a field that exists on the entity - (`AUTH_ROLES_FIELD_NOT_FOUND`) and that field must be `type: enum` - (`AUTH_ROLES_FIELD_NOT_ENUM`). The enum's `values`/`default` follow the normal - rules for an enum field. -- **Compile time:** The role enum is an ordinary field in the generated model. It is - not given any special storage; it is just another column on the record. -- **Runtime:** On each request the principal's role is read from the configured - `auth.roles` field on the identity record (falling back to a field named `roles` - then `role` if `auth.roles` is omitted). Role-scoped access rules match against - this value by exact comparison. +- **Parse time:** `roles.default` must be one of `roles.values` + (`ROLES_DEFAULT_UNKNOWN_ROLE`), and `roles.hierarchy` must name declared roles + without a cycle (see [Role hierarchy](./hierarchy.md)). `auth.roles` must name a + field of the entity (`AUTH_ROLES_FIELD_NOT_FOUND`) of `type: enum` + (`AUTH_ROLES_FIELD_NOT_ENUM`). A role named in an access rule must be one the entity + declares in `roles.values` or its role field (`ACCESS_RULE_INVALID`). +- **Runtime:** on each request the principal's roles are read from the `auth.roles` + field, or from `_auth.roles` when the entity names no role field. A principal + holds its roles plus every role its hierarchy includes. ## Storage -The role is stored as a plain field on the document, alongside the other fields. It -is NOT placed in a special namespace: +Managed roles are stored under `_auth.roles` on the identity record: ```json { "id": "abc-123", "email": "admin@example.com", "displayName": "Admin", - "role": "admin" + "_auth": { "passwordHash": "pbkdf2$sha256$...", "roles": "admin" } } ``` -Because the role is a regular field, it is returned in CRUD responses like any other -field. If clients should not be able to set or change their own role, restrict the -`create`/`update`/`replace` operations on the controller (see -[Access control](../controllers/access-control.md)); there is no automatic stripping -of the role field on write. +Every new record gets `roles.default`. To change a managed role, update the stored +record directly; the API offers no way to write it. With SQLite: -## Options +```sql +UPDATE users SET _auth = json_set(_auth, '$.roles', 'admin') WHERE email = 'admin@example.com'; +``` + +With MongoDB: + +```js +db.users.updateOne({ email: "admin@example.com" }, { $set: { "_auth.roles": "admin" } }) +``` + +A role field is stored as a plain field and returned in CRUD responses. A caller +sets it only to roles it holds itself, counting the roles its hierarchy includes: + +- On create, a record whose role field names a role the caller does not hold, or + that omits the field, gets the default role. +- On update or replace, a role change the caller does not hold is dropped and the + rest of the change applies. + +So a public `register` always yields the default role, and only an `admin` can make +another user an `admin`. -The role mechanism is two pieces: the `auth.roles` pointer and the enum field it -names. +## Options -| Field | Required | Type | Default | Description | -|-------|----------|------|---------|-------------| -| `auth.roles` | no | string | none | Name of the enum field that holds the role. Omit it and the authorizer falls back to a field named `roles` or `role`. | -| field `values` | yes (on the enum field) | list of strings | -- | All valid role names. | -| field `default` | no | string | none | Role assigned by the model default when none is supplied. See the note below on default-on-create. | +| Field | Required | Type | Description | +|-------|----------|------|-------------| +| `roles.values` | yes | list of strings | All valid role names. | +| `roles.default` | yes | string | Role of a new record. | +| `roles.hierarchy` | no | map of role to list of roles | Roles each role includes. See [Role hierarchy](./hierarchy.md). | +| `auth.roles` | no | string | Name of an `enum` field that holds the role. Its `values` are the roles and its `default` the role of a new record. | ## Using roles in access rules -Reference a role from a controller's `access` block. The forms are: +Reference a role from an access value. The forms are: -- `entity:role` -- principals of `entity` whose role equals `role` - (for example `user:admin`). +- `entity:role` -- principals of `entity` holding `role` (for example `user:admin`). - bare `role` -- shorthand for `entity:role` when there is exactly one auth entity. With two or more auth entities this is ambiguous and raises `ACCESS_AMBIGUOUS_ROLE` (use the scoped `entity:role` form). With no auth entity @@ -110,66 +151,31 @@ Reference a role from a controller's `access` block. The forms are: controllers: myCollection: service: myCollection - access: - list: public - create: user:admin # only users whose role is exactly "admin" - delete: user:* # any authenticated user + operations: + list: { access: public } + create: { access: user:admin } # users holding "admin" + delete: { access: user:* } # any authenticated user ``` -A role name in `entity:role` is matched by exact string comparison. There is no -implicit "admin is also a moderator" inheritance today (see below). - ## Examples -### Flat roles +### Multiple auth entities ```yaml version: "0.2" -api: - basePath: /api - version: v1 - entities: user: type: auth auth: identifier: email via: password - roles: tier + roles: + values: [admin, moderator, user] + default: user fields: + id: { type: id } email: { type: string, required: true, unique: true } - tier: - type: enum - values: [free, pro, enterprise] - default: free -``` - -`access: user:enterprise` matches only principals whose `tier` is exactly -`enterprise`; it does not match `pro` or `free`. - -### Multiple auth entities with separate role fields - -```yaml -version: "0.2" - -api: - basePath: /api - version: v1 - -entities: - user: - type: auth - auth: - identifier: email - via: password - roles: role - fields: - email: { type: string, required: true, unique: true } - role: - type: enum - values: [admin, moderator, user] - default: user apiClient: type: auth @@ -178,77 +184,44 @@ entities: via: apiKey roles: tier fields: + id: { type: id } clientId: { type: string, required: true, unique: true } apiKey: { type: string, required: true } tier: type: enum values: [free, premium, enterprise] default: free + +repositories: + users: + entity: user + apiClients: + entity: apiClient ``` Role names are scoped to their entity. `user:admin` and `apiClient:enterprise` are independent. With more than one auth entity, always use the scoped `entity:role` form -- a bare role name raises `ACCESS_AMBIGUOUS_ROLE`. -## Hierarchy and current limits - -Role hierarchy (declaring that one role transitively includes another) is configured -in a top-level `roles:` map keyed by auth-entity name, not on the entity: - -```yaml -roles: - user: - values: [admin, moderator, user] - default: user - hierarchy: - admin: [moderator] - moderator: [user] -``` - -This block is parsed and validated (an unknown entity name raises -`ROLES_UNKNOWN_ENTITY`), but the hierarchy is NOT yet applied during enforcement in -any target. Only exact role matches are checked: `access: user:moderator` matches -principals whose role is exactly `moderator`, not `admin`. Treat hierarchy as a -forward-looking declaration; for "admin OR something else" today, list multiple -rules or use a `when` ownership rule. See -[Role hierarchy](./hierarchy.md) for the full detail. - -The enum `default` populates the model default, but the create pipeline persists -only fields the client sends (`exclude_unset`). If a client omits the role field on -registration, the default is not written to the stored document, and the principal -resolves with no role until the field is set. To guarantee a role, supply it -server-side (for example via a custom handler) or require it on create. - ## Edge Cases -- **Role field must exist and be an enum.** If `auth.roles` names a field that is not - declared, the parser raises `AUTH_ROLES_FIELD_NOT_FOUND`. If the named field is not - `type: enum`, it raises `AUTH_ROLES_FIELD_NOT_ENUM`. - -- **Role read fallback.** When `auth.roles` is omitted, the authorizer reads a field - named `roles` (list) or `role` (string) if present. Naming the field explicitly via - `auth.roles` is clearer. - -- **Single role per user.** Each user holds one role string. Access matching compares - it exactly; there is no transitive expansion (hierarchy is inert, see above). - -- **Role is a normal field.** The role value is returned in CRUD responses and is - writable through `create`/`update`/`replace` unless those operations are restricted - on the controller. There is no built-in "promote user" endpoint; change a role via a - guarded update operation or a custom service handler. +- **One role per record.** A role field or managed store holds one role name; the + hierarchy decides which other roles it implies. -- **Entity without a role field.** If an auth entity declares no role field (and none - is named by `auth.roles`), its principals have no role. Role-scoped rules such as - `access: user:admin` never match those principals, though `"*"` and - `user:*` still do. +- **Entity without roles.** With neither a `roles:` block nor `auth.roles`, the + entity's principals hold no role. Role-scoped rules such as `access: user:admin` + never match them, though `"*"` and `user:*` still do. ## Errors | Condition | Error code | Message | |-----------|-----------|---------| +| `roles.default` is not in `roles.values` | `ROLES_DEFAULT_UNKNOWN_ROLE` | Default role '' is not one of the declared roles | +| `roles.hierarchy` names an undeclared role | `ROLES_HIERARCHY_UNKNOWN_ROLE` | Role hierarchy references undeclared role '' | +| `roles.hierarchy` is cyclic | `ROLES_HIERARCHY_CYCLE` | Role hierarchy for '' is cyclic through '' | | `auth.roles` names a missing field | `AUTH_ROLES_FIELD_NOT_FOUND` | Roles field '' not found in entity '' | | `auth.roles` names a non-enum field | `AUTH_ROLES_FIELD_NOT_ENUM` | Roles field '' must be of type 'enum' | -| Top-level `roles:` references an unknown auth entity | `ROLES_UNKNOWN_ENTITY` | Role hierarchy references unknown auth entity '' | +| An access rule names a role the entity does not declare | `ACCESS_RULE_INVALID` | role '' is not declared in entity '' | ## See Also diff --git a/docs/v0.2/Reference/services/custom-operations.md b/docs/v0.2/Reference/services/custom-operations.md index 399f3f66..55cb62ea 100644 --- a/docs/v0.2/Reference/services/custom-operations.md +++ b/docs/v0.2/Reference/services/custom-operations.md @@ -65,96 +65,34 @@ A custom controller operation only produces a route when the referenced service defines a matching custom operation; the controller operation name must match the service operation name. -## Handler signature (generated backends) +## Handler signature -The generated backend awaits the handler with its request object and uses the -returned value as the response body. FastAPI supplies `fastapi.Request`, DRF -supplies its request, and raw-ASGI supplies a compatible object with -`path_params`, `headers`, `state`, and an async `json()` method. +Every backend, and `stackform run`, calls the handler with one request object and +sends its return value as the JSON response body. The handler may be a plain or an +`async` function. ```python # myapp/handlers/ops.py from datetime import datetime, timezone -from fastapi import Request, HTTPException - - -async def publish_record(request: Request): - record_id = request.path_params["id"] - body = await request.json() - - # Do your own data access here. Import the generated repository/service, - # or talk to the database directly -- the handler is plain application code. - if body.get("draft"): - raise HTTPException(status_code=400, detail="Cannot publish a draft") +async def publish_record(request): + if request.body.get("draft"): + raise ValueError("Cannot publish a draft") return { - "id": record_id, + "id": request.params["id"], "published": True, "publishedAt": datetime.now(timezone.utc).isoformat(), } ``` -Notes: - -- The handler is invoked as `await handler(request)`. Read path and query - parameters from `request.path_params` / `request.query_params`, and the parsed - body with `await request.json()`. -- The handler owns its own data access and response shaping. There is no injected - repository, principal, or request context for this handler kind. -- Raise `fastapi.HTTPException` to return a controlled status. An uncaught - exception surfaces as a 500. -- Returning a dict or list serializes to JSON. You may also return a FastAPI - `Response` directly. - -Custom service-operation routes are generated by the FastAPI, DRF, and raw-ASGI -targets. Framework-specific response and exception classes remain specific to -their target. - -## Handler signature (`stackform run` dev server) - -The in-process dev server runs the same `handler:` declaration as a pipeline step. -The contract is different: the handler receives a `RequestContext`, sets -`context.response`, and returns the context. - -```python -# myapp/handlers/ops.py -from datetime import datetime, timezone - - -async def publish_record(context): - record_id = context.url_params["id"] - doc = await context.repository.get(record_id) - if doc.get("published"): - context.response = {"detail": "Already published"} - return context - - context.response = await context.repository.update( - record_id, - {"published": True, "publishedAt": datetime.now(timezone.utc).isoformat()}, - ) - return context -``` - -The `RequestContext` exposes: - -| Attribute | Description | -|-----------|-------------| -| `context.repository` | Repository instance bound to the service's repository | -| `context.url_params` | Path and query parameters | -| `context.body` | Parsed request body (or `None`) | -| `context.principal` | Authenticated principal as a dict (`principal["sub"]`, `principal["identity_collection"]`, `principal["record"]`) or `None` | -| `context.headers` | Request headers | -| `context.response` | Set this to the value you want serialized as the response | - -For controlled "not found" handling under the dev server, raise the shared error: - -```python -from yaml_api_generator.server.common.steps import NotFoundError -``` - -If you want one handler module to run under both modes, branch on what it -receives, or keep separate handlers and point each spec at the right one. +- Read path parameters from `request.params`, query parameters from + `request.query`, the JSON body from `request.body` and the caller from + `request.principal`; see [Handler context](./handler-context.md) for every + attribute. +- The handler owns its data access: import the client or service it needs. +- Raising `ValueError` answers 422 with its message as `detail`; any other + exception answers 500. ## Access control @@ -163,23 +101,19 @@ Set `access:` on the controller operation exactly as you would for `create`, `update`, etc. The example above gates `publish` with `"*"` (any authenticated principal) and `archive` with the `moderator` role. -> **Enforcement status** +> **Enforcement** > -> Roles (exact match), `:*` scoping, and ownership/ABAC `when` rules +> Roles, role hierarchies, `:*` scoping, and ownership/ABAC `when` rules > are enforced by the generated FastAPI backend, the generated DRF backend, and the -> `stackform run` dev server alike. A request that fails a rule gets 401 (missing or -> invalid credentials), 403 (authenticated but not allowed), or 404 (record not -> found, checked before ownership). Two current limitations: role hierarchy (for -> example `admin` inheriting `moderator`) is accepted in YAML but not yet applied -> during enforcement -- only exact role matches are checked; and conditional or -> ownership rules require a v0.2 spec, because v0.1 specs do not parse `when` rules. +> `stackform run` dev server alike. A request that no rule admits gets 401 when it +> carries no valid credentials and 403 otherwise; a missing record answers 404 +> before ownership is checked. ## Repository-level operations (declarative alternative) If you only need a database query and not arbitrary Python, declare the operation on the repository instead of writing a handler. A repository operation is either a -`query:` (a named MongoDB command) or a `handler:` invoked as -`await handler(**kwargs)`: +`query:` (a named MongoDB command) or a `handler:` called with the request object: ```yaml repositories: @@ -198,12 +132,10 @@ repositories: `findOne`, `aggregate`, `countDocuments`, `distinct`, `updateMany`, `deleteMany`). Some commands require a field (`find`/`findOne` require `filter`). A repository without an `entity:` must set `query.collection` -(`QUERY_MISSING_COLLECTION`). An operation that has neither `query:` nor `handler:` -is rejected (`OPERATION_MISSING_QUERY_OR_HANDLER`); an unknown command is +(`QUERY_MISSING_COLLECTION`). An operation declares exactly one of `query:` or +`handler:`; neither or both is a `SCHEMA_VALIDATION_ERROR`. An unknown command is `QUERY_COMMAND_UNKNOWN`. -The two handler kinds differ in their arguments: a service custom-operation handler -receives the raw request (FastAPI) or a `RequestContext` (run), while a repository -`operations..handler` is called with keyword arguments. See -[Repository operations](../repositories/operations.md) for the full repository -operation model. +A repository `operations..handler` receives the same request object as a +service handler. See [Repository operations](../repositories/operations.md) for the +full repository operation model. diff --git a/docs/v0.2/Reference/services/handler-context.md b/docs/v0.2/Reference/services/handler-context.md index 0f310871..47d92e3d 100644 --- a/docs/v0.2/Reference/services/handler-context.md +++ b/docs/v0.2/Reference/services/handler-context.md @@ -1,142 +1,55 @@ --- title: "Handler Context" -description: Custom operation handlers run under two contracts. Generated backends pass a target request object; the stackform run dev server passes a RequestContext pipeline object. This page documents both. +description: Every custom handler receives one request object, the same under stackform run and every generated backend, and returns the JSON response body. --- -A custom operation runs different code depending on how you execute your spec. The -generated backends invoke your handler with their target request object. The -raw-ASGI target supplies a compatible request facade. -The `stackform run` dev server invokes your handler as a pipeline step that receives a -`RequestContext` and returns it. Write your handler for the target you intend to run; -the two contracts are not interchangeable. +A custom handler is a plain or `async` Python function that receives one request +object and returns the JSON response body. The contract is the same under +`stackform run` and every generated backend (FastAPI, DRF, raw ASGI), and for every +place a handler is declared: -Both kinds are wired through `services..operations..handler` plus an -`imports:` mapping, and both are exposed as routes through a controller operation that -sets `method:` and `path:` (see [Custom operations](./custom-operations.md)). - -## Generated backend handler (`build`) - -The generated route calls your function with the target request object: - -```python -from fastapi import Request -from services.exceptions import NotFoundError - -async def publish(request: Request): - entity_id = request.path_params["id"] - body = await request.json() - ... - return {"id": entity_id, "status": "published"} -``` - -| Source | How to read it | -|--------|----------------| -| Path parameters | `request.path_params["id"]` | -| Query parameters | `request.query_params.get("draft")` | -| Request body | `await request.json()` | -| Headers | `request.headers.get("authorization")` | - -The principal is NOT injected, and custom FastAPI routes are NOT run through the -authentication or authorization pipeline: the generated route invokes your handler -directly, with no `require_principal` and no access-rule check, even when neighboring -CRUD routes on the same controller are protected. If the action must be restricted, -read the `Authorization` / `X-API-Key` header and verify the caller inside your -handler. (The `stackform run` dev server differs: there a custom handler runs after the -route's authenticate and authorize steps, and the resolved caller is available as -`context.principal` -- see below.) - -Your handler owns its database access (import the service or repository it needs) and -owns its response: return a JSON-serializable value (dict or list) and it is sent as -the body. An uncaught exception becomes a 500. The CRUD-route mapping of -`NotFoundError` to 404 and `ValidationError` to 422 does NOT apply to custom handlers; -raise `fastapi.HTTPException` to return a controlled status code. - -```python -from fastapi import HTTPException - -async def publish(request: Request): - entity_id = request.path_params["id"] - if not entity_id: - raise HTTPException(status_code=400, detail="Missing entity id") - ... -``` - -## `run` dev server handler (`stackform run`) - -Under `stackform run`, a handler is a pipeline step. It receives the request -`RequestContext`, mutates it, and returns it: +- a service custom operation, `services..operations..handler` +- a service handler, `services..handler` (see [Service `input:`](../../../v0.3/service-input.md)) +- a repository operation, `repositories..operations..handler` ```python -from yaml_api_generator.server.common.steps import NotFoundError - -async def publish(context): - entity_id = context.url_params["id"] - doc = await context.repository.get(entity_id) - if doc is None: - raise NotFoundError("Entity not found") - if doc["status"] == "published": - raise ValueError("Already published") - context.response = await context.repository.update( - entity_id, - {"status": "published"}, - ) - return context +async def publish(request): + if "reason" not in request.body: + raise ValueError("A reason is required") + return {"id": request.params["id"], "by": request.principal["sub"], "reason": request.body["reason"]} ``` -### `context` attributes - -| Attribute | Type | Description | -|-----------|------|-------------| -| `context.repository` | repository | The repository bound to this service's entity. Provides `list()`, `get(id)`, `create(data)`, `update(id, data)`, `replace(id, data)`, `delete(id)`. | -| `context.url_params` | `dict` | Path parameters. For a route with `/{id}`, use `context.url_params["id"]`. | -| `context.body` | `dict` or `None` | The parsed JSON request body. `None` if the request had no body. | -| `context.headers` | `dict` | Request headers (lowercased keys). | -| `context.principal` | `dict` or `None` | The authenticated caller, or `None` on a public/unauthenticated route. | -| `context.method` / `context.path` | `str` | The HTTP method and matched path. | -| `context.response` | `Any` | What you set here is JSON-encoded and returned. A handler MUST assign this and `return context`. | +## Request attributes -There is no `context.now()`; use `datetime.now(timezone.utc)` from the standard -library if you need a timestamp. +| Attribute | Value | +|-----------|-------| +| `request.params` | Route path parameters, e.g. `request.params["id"]` for `/{id}` | +| `request.query` | Query parameters; a repeated key keeps its last value | +| `request.headers`, `request.cookies` | Request headers and cookies | +| `request.body` | The JSON body, or `{}` when the body is empty or not JSON | +| `request.principal` | The signed-in principal, or `None` on a public route | +| `request.inputs` | The service's `with:` values, keyed by their `with:` names | -### `principal` keys +`request.principal` is a plain dict: -When `context.principal` is not `None` it is a plain dict, not an object. Index it: +| Key | Value | +|-----|-------| +| `sub` | The caller's primary-key value | +| `identity_collection` | The auth entity the caller belongs to | +| `roles` | The caller's roles, as a list | +| `record` | The caller's stored record, without the stored id and password | -| Key | Type | Description | -|-----|------|-------------| -| `principal["sub"]` | `str` | The caller's primary-key value (their id). | -| `principal["identity_collection"]` | `str` | The auth entity the caller belongs to. | -| `principal["record"]` | `dict` | The caller's stored document, with `_auth` and `password` removed. Read other identity fields via `principal["record"][""]`. | -| `principal["roles"]` | `list[str]` | The caller's roles, present only when the identity declares a roles field. | +## Access and data -### Error handling +A handler route runs the controller operation's `access:` rules before the handler, +exactly like a built-in operation: a request no rule admits gets 401 without valid +credentials and 403 otherwise. The handler owns its data access: import the client +or service it needs. -Raise exceptions to map onto HTTP status codes: - -| Exception | HTTP status | Use case | -|-----------|-------------|----------| -| `NotFoundError` | 404 Not Found | Record does not exist. Import from `yaml_api_generator.server.common.steps`. | -| `ValueError` | 422 Unprocessable Entity | Invalid input or a business-rule violation. | -| `UnauthorizedError` | 401 Unauthorized | Missing or invalid credentials. | -| `ForbiddenError` | 403 Forbidden | Authenticated but not allowed. | -| Any other exception | 500 Internal Server Error | Unexpected errors. | - -## Repository-level handlers - -A repository operation can also point at a handler: - -```yaml -repositories: - myCollection: - operations: - recent: { handler: my_collection.recent } -``` +## Response and errors -This handler is called differently from the two request-handler contracts above: the -generated repository invokes it as `await (**kwargs)`, passing the operation's -arguments as keyword arguments. There is no `Request` and no `RequestContext`. Use this -form for data-access logic that the service layer drives; see -[Repository operations](../repositories/operations.md). +The return value is sent as the JSON body with status 200. Raising `ValueError` +answers 422 with the exception message as `detail`. Any other exception answers 500. ## See Also diff --git a/docs/v0.2/Reference/services/overview.md b/docs/v0.2/Reference/services/overview.md index c581c377..5af66d4b 100644 --- a/docs/v0.2/Reference/services/overview.md +++ b/docs/v0.2/Reference/services/overview.md @@ -55,6 +55,8 @@ The service key must match a repository key of the same name for this shorthand - `repositories..crud: [...]` selects the subset that repository exposes. - `services..operations.` is the final override (a boolean, or a `{ handler }` map). +A boolean switch names a CRUD operation (or, on an auth entity, an auth operation) or a custom operation the repository declares; any other name, such as a misspelled `lsit: false`, fails with `DESUGAR_SERVICE_OPERATION_UNKNOWN`. To add a new operation, give it a `{ handler }` map instead. + Resolution precedence, highest first: service `operations` > repository `crud` > `api.operations.defaults` > all enabled. See [Composable per-operation CRUD](../repositories/composable-crud.md) and [Repository operations](../repositories/operations.md). ## Per-operation overrides @@ -83,7 +85,7 @@ The controller route is required for custom (non-CRUD) operations; without `meth ```yaml imports: - handlers: my-app.handlers + handlers: my_app.handlers services: myCollection: @@ -96,30 +98,27 @@ controllers: service: myCollection base_path: /my-collection operations: - publish: { method: POST, path: /{id}/publish } + publish: { method: POST, path: "/{id}/publish" } ``` With `api: { basePath: /api, version: v1 }` this exposes `POST /api/v1/my-collection/{id}/publish`. ### Handler contract -The handler is a Python function referenced by `importKey.functionName`. Generated -backends pass their request object (the raw-ASGI target supplies a compatible -request facade): +The handler is a Python function referenced by `importKey.functionName`. Every +backend and `stackform run` call it with the same request object and send its +return value as the response body: ```python async def publish(request): - entity_id = request.path_params["id"] - body = await request.json() - ... - return {"id": entity_id, "published": True} + return {"id": request.params["id"], "published": True, "note": request.body.get("note")} ``` -The handler does its own data access and owns its response; an uncaught exception becomes a 500. The `stackform run` dev server uses a different handler shape (a request-context pipeline step). Both contracts are documented in [Custom operations](custom-operations.md) and [Handler context](handler-context.md). +The handler does its own data access. Raising `ValueError` answers 422; any other exception answers 500. Every request attribute is listed in [Handler context](handler-context.md). ## Top-level `handler` vs per-operation `handler` -- A top-level `handler` on a service replaces the whole service group with one custom function (no CRUD units are generated for it). +- A top-level `handler` on a service replaces the whole service group with one custom function (no CRUD units are generated for it). It cannot be combined with `operation`. - `operations..handler` adds a single custom operation alongside the repository-backed CRUD operations. ```yaml @@ -132,13 +131,42 @@ services: publish: { handler: handlers.publish } # one extra operation ``` +## Inheritance with `abstract` and `extends` + +An `abstract: true` service is a template: no controller may reference it. A service with `extends: ` inherits every key of that abstract service except `abstract` and `extends`; its own keys win and `operations` merge per operation. An abstract service may itself extend another abstract service: a service inherits from every level of the chain, and the nearest declaration of an operation wins, so an extending service replaces an inherited handler by declaring the operation again. + +```yaml +services: + publishable: + abstract: true + operations: + publish: { handler: handlers.publish } + articles: + repository: articles + extends: publishable + pages: + repository: pages + extends: publishable + operations: + delete: false +``` + +When the abstract service declares `operations`, the generated backend emits it as a base class that every extending service subclasses. + +| Code | Cause | +|------|-------| +| `SERVICE_EXTENDS_NOT_FOUND` | `extends` names a service that does not exist. | +| `SERVICE_EXTENDS_NOT_ABSTRACT` | `extends` names a service without `abstract: true`. | +| `SERVICE_CIRCULAR_EXTENDS` | An `extends` chain loops back on itself. | +| `DESUGAR_ABSTRACT_SERVICE_BOUND` | A controller references an abstract service. | + ## Imports `handler:` values are dotted references of the form `importKey.functionName`. The leading key is looked up in the top-level `imports:` map and the rest is appended; if the key is not declared, the value is treated as a verbatim dotted module path. ```yaml imports: - handlers: my-app.handlers # handlers.publish -> my-app.handlers.publish + handlers: my_app.handlers # handlers.publish -> my_app.handlers.publish ``` Declaring the alias in `imports:` keeps handler references short and is recommended, but is not strictly required. @@ -183,7 +211,7 @@ api: login: true imports: - handlers: my-app.handlers + handlers: my_app.handlers services: users: {} # all CRUD + register/login (auth entity) diff --git a/docs/v0.2/Reference/ui/collections.md b/docs/v0.2/Reference/ui/collections.md index 8b69705b..33dc4f94 100644 --- a/docs/v0.2/Reference/ui/collections.md +++ b/docs/v0.2/Reference/ui/collections.md @@ -38,9 +38,9 @@ Each collection has a `fields` map. Each entry maps a display name to a definiti | `from` | string | Entity traversal path `.[refField...].`. Required. | | `format` | string | Free-form, pack-interpreted hint (for example `rich_text`). Optional. | -`format` is passed through verbatim to the pack. It is not validated against a fixed -set of values, and the built-in `generic` pack does not currently read it; only a -custom pack that inspects the collection field map can act on it. +`format` is passed through verbatim to the pack and is not validated against a +fixed set of values. The built-in `generic` pack ignores it; a custom pack that +inspects the collection field map can act on it. ## Traversal Rules diff --git a/docs/v0.2/Reference/ui/components.md b/docs/v0.2/Reference/ui/components.md index 1873aa1e..bd55dcfe 100644 --- a/docs/v0.2/Reference/ui/components.md +++ b/docs/v0.2/Reference/ui/components.md @@ -5,7 +5,7 @@ description: Declare reusable named components composed from the generic pack's The `ui.components` block lets you declare named components built from the building blocks shipped by the frontend pack (`generic` is the only built-in -pack today). A declared component can then be referenced by a view or nested +pack). A declared component can then be referenced by a view or nested inside another component. The whole `ui` block is free-form in the JSON schema (`{"type": "object"}`): @@ -69,6 +69,38 @@ The `generic` pack provides kebab-case components, grouped by role. Common ones: Names such as `Card`, `Button`, `Badge`, and `Input` are internal scaffold primitives, not components you reference here. Use the kebab-case names above. +### Generic pack props + +Each component accepts only the props it declares, typed as declared; any other +key fails schema validation. Props of type `function` are callbacks the generated +page wires itself and cannot be set in a spec. + +| Component | Prop | Effect | +|-----------|------|--------| +| `list-table`, `table` | `columns` | Fields shown as columns, in order (default: the first four by `field_roles`) | +| | `searchable` | Search box filtering the listed rows | +| | `sortable` | Clickable column headers that sort the rows | +| | `pagination` | Page the rows instead of listing them all | +| | `selectable` | Row checkboxes and a count of the selected rows | +| `list-card-grid` | `columns` | Number of cards per row | +| | `searchable` | Search box filtering the cards | +| `list-grouped` | `collapsible` | Group headers that fold their rows | +| `list-spreadsheet` | `inline_edit` | Cells editable in place through the update operation | +| `detail-article` | `public` | Fetch the record without the session token | +| `form-sectioned` | `sections` | `[{ title, fields }]` groups replacing the default sections | +| `empty-centered` | `message`, `action_label` | Text and create-button label of the empty state | +| `empty-import` | `options` | Which of `create`, `import`, `sample` are offered | +| `empty-filtered` | `message` | Text of the empty filtered state | +| `delete-type-confirm` | `confirm_text` | Text the user types to confirm (default: the entity name) | +| `toast-undo` | `message`, `duration` | Toast text and seconds before it closes | +| `filter-toolbar` | `active_filters` | Filters applied when the page opens, as `{ field: value }` | +| `command-palette` | `collections` | Further collections whose records the palette searches | +| `bulk-floating`, `bulk-toolbar`, `context-menu` | `actions` | Which selection actions are offered | +| `bulk-progress` | `action` | Label of the bulk deletion | +| `settings-sectioned` | `sections` | Which settings sections are shown | +| `auth-hero-split` | `providers` | Which external providers get a sign-in button (default: all served) | +| `crud` | `list`, `form`, `detail`, `delete` | The component filling each slot (see [UI Views](views)) | + ## Prop values A prop value is interpreted by its shape: @@ -106,7 +138,7 @@ a declared prop of the component. Otherwise generation fails with `render uses $ref '' but it's not in declared props`. Declared props are not checked for being unused. -## Rendering behavior today +## Rendering behavior The render tree is fully parsed and validated, but the generic pack renders only the root pack component of a view. When a view references a declared component, @@ -115,11 +147,10 @@ component and renders that component's template, driven by the view's entity and collection props. The abstract render tree (nested nodes, `$ref` wiring, literal props inside -`render`) is validated and recorded but is not yet composed into the generated -output by the `generic` pack. In practice, declaring a component is useful for -naming and for choosing the root pack component (and, via `crud`/`split-pane`, -its sub-components through component references); deeper hand-authored trees are -accepted but inert until a pack consumes them. +`render`) is validated and recorded for packs; the `generic` pack renders only the +root pack component. With the `generic` pack, a declared component names a view +and chooses its root pack component (and, via `crud`/`split-pane`, its +sub-components through component references). ## Using a component in a view diff --git a/docs/v0.2/Reference/ui/frontend.md b/docs/v0.2/Reference/ui/frontend.md index 1b4c9de9..4efc8d6c 100644 --- a/docs/v0.2/Reference/ui/frontend.md +++ b/docs/v0.2/Reference/ui/frontend.md @@ -115,11 +115,12 @@ environment variable. The `generic` pack scaffolds a complete app shell around your views: - A sidebar layout with navigation and an always-present Dashboard. -- A login gate and `useAuth()` hook when `require_auth` is true, including SSO - buttons for any external providers bound to an identity entity. +- A login page and `useAuth()` hook when an auth entity exists, a login gate on + every route when `require_auth` is true, and SSO buttons for any external + providers bound to an identity entity. - Per-entity data hooks and a typed API client under `src/__generated__/`. - Pages generated from your [views](views), with actions and routes gated by - each repository's CRUD set (see [Operation-gated UI actions](operation-gating)). + each repository's CRUD set (see [Operation and role gating](operation-gating)). The rest of the `ui:` block is documented separately: @@ -127,4 +128,4 @@ The rest of the `ui:` block is documented separately: - [Collections](collections) -- flattened, formatted presentation models. - [Components](components) -- the `generic` component catalog and custom components. - [Views](views) -- pages, routes, the `crud` composite, and `field_roles`. -- [Operation-gated UI actions](operation-gating) -- how CRUD selection drives the UI. +- [Operation and role gating](operation-gating) -- how CRUD selection and roles drive the UI. diff --git a/docs/v0.2/Reference/ui/operation-gating.md b/docs/v0.2/Reference/ui/operation-gating.md index ec9bf0a3..9c4a53e8 100644 --- a/docs/v0.2/Reference/ui/operation-gating.md +++ b/docs/v0.2/Reference/ui/operation-gating.md @@ -1,140 +1,173 @@ --- -title: "Operation-Gated UI Actions" -description: The generated React app only renders actions and routes for the CRUD operations each repository actually exposes. +title: "Operation and Role Gating" +description: The generated React app shows an action or route only when the server would serve that operation to the signed-in caller. --- -The generated React app never shows an action the backend cannot serve. Every +The generated React app never offers an action the server would refuse. Every button (New, View, Edit, Delete) and every client-side route (`/new`, `/:id`, -`/:id/edit`) is gated on the CRUD operations that the backing repository's -`crud` set exposes. Disable an operation in your spec and its UI affordance -disappears along with its HTTP route. This is the frontend half of -[composable per-operation CRUD](../repositories/composable-crud). +`/:id/edit`) passes two gates: -## Where the operation set comes from +1. **Operation gate**, at build time: the operation exists, because the backing + repository's `crud` set exposes it. An absent operation emits no button and + no route. +2. **Access gate**, at run time: the signed-in caller holds a role the + operation's access rules admit, with the role hierarchy applied. A refused + operation hides its button, and its route renders a "no access" page (or the + login page for an anonymous caller). -Each entity rendered in the UI carries the exact set of CRUD operations its -repository exposes. That set is derived from the routes the generator emits, -which in turn come from the repository's `crud` list (default: all six of -`list`, `get`, `create`, `update`, `replace`, `delete`). Drop an operation from -`crud` and it is absent from the repository, the service, the HTTP route, and -the UI. +## Example ```yaml +version: "0.2" + api: basePath: /api - version: v1 + +entities: + member: + type: auth + auth: { identifier: handle, via: password } + roles: + values: [owner, editor, viewer] + default: viewer + hierarchy: + owner: [editor] + editor: [viewer] + fields: + id: { type: id } + handle: { type: string, required: true, unique: true } + doc: + fields: + id: { type: id } + title: { type: string, required: true } + note: + fields: + id: { type: id } + body: { type: string, required: true } repositories: - myCollection: - entity: myEntity - crud: [create, list, get, update, delete] - myOtherCollection: - entity: myOtherEntity + members: { entity: member } + docs: + entity: doc + crud: [list, get, create, update, delete] + notes: + entity: note crud: [create, list, get, delete] # no update -> no Edit anywhere -``` - -With this spec, `myCollection` renders the full action set; `myOtherCollection` -renders everything except Edit, because `update` is not in its `crud` list. - -## Per-surface gating - -The pack gates actions on the surface where they live. The mapping from CRUD -operation to UI affordance is: - -| Operation | Surface | Affordance gated on it | -|-----------|---------|------------------------| -| `create` | List page | "New" button (and the empty-state "Create your first record" button) | -| `get` | List page | "View" link on each row | -| `update` | Detail page | "Edit" button | -| `delete` | Detail page | "Delete" button | - -If an operation is not in the entity's set, its button or link is simply not -emitted into the generated `.tsx` page. - -## Route gating - -A `crud` view generates three page files (the list, a form, and a detail page) -and registers up to four routes. The list route is always present; the other -three are gated identically to the buttons that navigate to them: - -| Route | Renders | Gated on | -|-------|---------|----------| -| `/my-collection` | List page | always (the view's `path`) | -| `/my-collection/new` | Form (create) | `create` | -| `/my-collection/:id` | Detail page | `get` | -| `/my-collection/:id/edit` | Form (edit) | `update` | -Because the route is omitted, navigating to it directly resolves to nothing. -There is no Edit button to reach `/my-collection/:id/edit`, and the route itself -is not registered. +services: + docs: { repository: docs } + notes: { repository: notes } + +controllers: + docs: + service: docs + base_path: /docs + access: member:viewer + operations: + create: { access: member:editor } + update: { access: member:editor } + delete: { access: member:owner } + notes: + service: notes + base_path: /notes + access: member:viewer -The `crud` composite component wires all of this for you: - -```yaml ui: - frontend: - pack: generic - config: - app_name: My App + frontend: { pack: generic, output: ./frontend } + config: { app_name: Docs } views: - myCollection: - path: /my-collection + docs: + path: /docs component: crud - props: - entity: myEntity - myOtherCollection: - path: /my-other-collection + props: { collection: doc } + notes: + path: /notes component: crud - props: - entity: myOtherEntity + props: { collection: note } ``` -For `myOtherCollection` (no `update`), the generated app omits the Edit button -on the detail page and never registers `/my-other-collection/:id/edit`. Create, -View, and Delete all remain because `create`, `get`, and `delete` are in its -`crud` list. - -```mermaid -flowchart TD - crud["repository crud: [create, list, get, delete]"] --> ops["entity operation set"] - ops --> c{create?} - ops --> g{get?} - ops --> u{update?} - ops --> d{delete?} - c -- yes --> cnew["New button + /my-other-collection/new route"] - g -- yes --> gview["View link + /my-other-collection/:id route"] - u -- no --> unone["no Edit button, no /my-other-collection/:id/edit route"] - d -- yes --> ddel["Delete button"] -``` +With this spec: + +| Caller | Docs | Notes | +|--------|------|-------| +| viewer | list, View | list, View, New, Delete | +| editor | list, View, New, Edit | list, View, New, Delete | +| owner | list, View, New, Edit, Delete | list, View, New, Delete | + +Notes never show Edit, because `update` is not in its repository's `crud` list. +An editor sees what a viewer sees because `editor` includes `viewer`; an owner +sees everything because `owner` includes `editor`. + +## Operation gate + +Each entity rendered in the UI carries the CRUD operations its repository +exposes, taken from the repository's `crud` list (default: all six of `list`, +`get`, `create`, `update`, `replace`, `delete`). An operation absent from `crud` +is absent from the repository, the service, the HTTP route and the UI. + +| Operation | Surface | Affordance | +|-----------|---------|------------| +| `create` | List page | "New" button (and the empty-state create button) | +| `get` | List page | "View" link on each row | +| `update` | Detail page | "Edit" button | +| `delete` | Detail page | "Delete" button, from the view's `delete` component | + +A `crud` view registers up to four routes, each only when its operation exists: + +| Route | Renders | Needs | +|-------|---------|-------| +| `/docs` | List page | `list` | +| `/docs/new` | Form (create) | `create` | +| `/docs/:id` | Detail page | `get` | +| `/docs/:id/edit` | Form (edit) | `update` | + +## Access gate + +The app generates `src/__generated__/access.ts`, the same decision the server +makes, as data: + +- `ACCESS[entity][operation][column]` is the verdict for each column of callers: + `anonymous`, and one column per identity role (`member.owner`, + `member.editor`, `member.viewer`), or one per identity entity without roles. + A verdict is `allow`, `deny`, or `conditional` when the admitting rule has a + `when` condition the server checks against the record. +- `ROLES[identity].includes` lists, per role, every role it includes, flattened + from `entities..roles.hierarchy` (`owner: [editor, viewer]` above). +- `allows(entity, operation, caller)` is true when some column the caller + occupies has a verdict other than `deny`. A caller occupies a role column when + it holds that role or a role that includes it. A `conditional` verdict shows + the action and leaves the record check to the server. +- `grants(caller, role)` is true for the roles the caller holds, directly or by + inclusion. A role select disables every other option, since the server drops a + role the caller cannot grant. + +The caller comes from the session: password login and the code exchange that ends +an OIDC or SAML sign-in both answer the signed-in record and the roles it holds, +whether they are managed roles or a role field. ## Login gate -When the spec declares any `type: auth` entity, the app wraps every route in an -authentication gate. The gate is controlled by `ui.config.require_auth`, which -defaults to `true`: +When the spec declares a `type: auth` entity, the app has a login page. With +`ui.config.require_auth: true`, every route shows the login page until a session +exists; it defaults to `false`, leaving pages open and gated by the access gate +alone: ```yaml ui: config: - require_auth: true # default; set false to leave the app open + require_auth: true ``` -With the gate on, an unauthenticated visitor is shown the login page and only -sees the app shell once a token is stored. The generated app exposes a -`useAuth()` hook (`token`, `user`, `login`, `logout`); the sidebar shows a -Logout action when signed in and a Login link otherwise. - -External identity providers bound to the auth entity are surfaced as SSO -buttons on the login page. The app fetches the provider list from -`GET {basePath}[/{version}]/{collection}/auth/providers` and renders a -"Continue with ..." button for each, redirecting to -`{basePath}[/{version}]/{collection}/auth/{provider}` to start the flow. See +The generated app exposes a `useAuth()` hook (`token`, `user`, `caller`, `login`, +`logout`). External identity providers bound to the auth entity appear as sign-in +buttons on the login page, read from +`GET {basePath}[/{version}]/{collection}/auth/providers`. See [Login, SSO, and the token flow](../auth/login-and-sso) for the backend side. ## See also - [Composable per-operation CRUD](../repositories/composable-crud) - the - backend mechanism this gating mirrors. + repository `crud` list the operation gate mirrors. +- [Roles](../roles/overview) and [role hierarchy](../roles/hierarchy) - the + rules the access gate mirrors. - [UI Views](views) - how a `crud` view expands into pages and routes. -- [Authentication required by default](../auth/auth-required-by-default) - how - routes are protected once an auth entity exists. diff --git a/docs/v0.2/Reference/ui/overview.md b/docs/v0.2/Reference/ui/overview.md index 14bc2e56..c16642bd 100644 --- a/docs/v0.2/Reference/ui/overview.md +++ b/docs/v0.2/Reference/ui/overview.md @@ -75,7 +75,7 @@ file, and its `path` becomes both a React Router route and a sidebar nav item. A to entity field names so the pack components know what to render. The `crud` composite component is the common case for a managed resource. A single -`crud` view emits three page files (list, form, detail) and up to four routes: +`crud` view emits four page files (list, form, detail, delete) and up to four routes: ```yaml views: @@ -98,7 +98,8 @@ The generated app only renders actions and routes for CRUD operations the backin repository actually exposes. If a repository's `crud:` set omits `update`, the detail page shows no Edit button and the `/:id/edit` route is not emitted; omitting `create` removes the New action and the `/new` route. This keeps the UI in step with -the backend surface. See the [operation-gating reference](operation-gating.md) and +the backend surface. Actions the signed-in caller's roles do not admit are hidden +too. See [Operation and role gating](operation-gating.md) and [Composable per-operation CRUD](../repositories/composable-crud.md). ## Authentication diff --git a/docs/v0.2/Reference/ui/views.md b/docs/v0.2/Reference/ui/views.md index 9519f33a..59c4d466 100644 --- a/docs/v0.2/Reference/ui/views.md +++ b/docs/v0.2/Reference/ui/views.md @@ -58,7 +58,7 @@ shell, and registers a route at the view's `path`. ## The `crud` composite `component: crud` is the high-level way to wire a full resource UI. One view -generates three page files and four routes: +generates four page files and four routes: ```yaml ui: @@ -77,7 +77,8 @@ ui: Generated for the view above: - Page files: `MyCollection.tsx` (list), `MyCollectionForm.tsx` (create/edit), - `MyCollectionDetail.tsx` (single record). + `MyCollectionDetail.tsx` (single record), `MyCollectionDelete.tsx` (the Delete + button and its dialog, shown on the detail page). - Routes: `/my-collection` (list), `/my-collection/new` (create form), `/my-collection/:id` (detail), `/my-collection/:id/edit` (edit form). @@ -90,7 +91,7 @@ The `:id`, `/new`, and `/:id/edit` routes are operation-gated: each one is emitt only if the collection's backing repository exposes the corresponding CRUD operation. With `crud: [list, get]` on the repository, the create form (`/new`), edit form (`/:id/edit`), and their New/Edit buttons are all omitted, and the page -becomes read-only. See [Operation-gated UI actions](operation-gating.md) and +becomes read-only. See [Operation and role gating](operation-gating.md) and [Composable per-operation CRUD](../repositories/composable-crud.md). ## `field_roles` @@ -142,16 +143,13 @@ SSO login lands on `/auth/callback?code=...`, which the app shell redeems for a at `/auth/exchange`. See [Login, SSO, and the token flow](../auth/login-and-sso.md). -> **Enforcement status** +> **Enforcement** > -> Roles (exact match), `:*` scoping, and ownership/ABAC `when` rules +> Roles, role hierarchies, `:*` scoping, and ownership/ABAC `when` rules > are enforced by the generated FastAPI backend, the generated DRF backend, and the -> `stackform run` dev server alike. A request that fails a rule gets 401 (missing or -> invalid credentials), 403 (authenticated but not allowed), or 404 (record not -> found, checked before ownership). Two current limitations: role hierarchy (for -> example `admin` inheriting `moderator`) is accepted in YAML but not yet applied -> during enforcement -- only exact role matches are checked; and conditional or -> ownership rules require a v0.2 spec, because v0.1 specs do not parse `when` rules. +> `stackform run` dev server alike. A request that no rule admits gets 401 when it +> carries no valid credentials and 403 otherwise; a missing record answers 404 +> before ownership is checked. The UI gates which controls it shows, but it is not a security boundary: the generated backend enforces access on every request regardless of what the UI @@ -179,4 +177,4 @@ A view's `component` may name a `generic` pack component directly, or a custom component declared in `ui.components`. A custom component resolves through its render tree down to the root pack component, which determines the page layout. The generic pack renders only that root pack component; nested render-tree props are -validated but inert today. See [UI Components](components.md). +validated but not rendered. See [UI Components](components.md). diff --git a/docs/v0.2/Reference/when-expressions/operators.md b/docs/v0.2/Reference/when-expressions/operators.md index 41aa3e0d..53192a7d 100644 --- a/docs/v0.2/Reference/when-expressions/operators.md +++ b/docs/v0.2/Reference/when-expressions/operators.md @@ -9,16 +9,13 @@ A `when:` expression appears inside an access-rule object on a controller. It co field references (the principal and a bound document) using the operators below and decides whether the rule matches. -> **Enforcement status** +> **Enforcement** > -> Roles (exact match), `:*` scoping, and ownership/ABAC `when` rules +> Roles, role hierarchies, `:*` scoping, and ownership/ABAC `when` rules > are enforced by the generated FastAPI backend, the generated DRF backend, and the -> `stackform run` dev server alike. A request that fails a rule gets 401 (missing or -> invalid credentials), 403 (authenticated but not allowed), or 404 (record not -> found, checked before ownership). Two current limitations: role hierarchy (for -> example `admin` inheriting `moderator`) is accepted in YAML but not yet applied -> during enforcement -- only exact role matches are checked; and conditional or -> ownership rules require a v0.2 spec, because v0.1 specs do not parse `when` rules. +> `stackform run` dev server alike. A request that no rule admits gets 401 when it +> carries no valid credentials and 403 otherwise; a missing record answers 404 +> before ownership is checked. ## Comparison Operators @@ -28,7 +25,7 @@ Returns `true` if the left operand equals the right operand. Use the word `equal not `==`. ```yaml -when: principal equals document.authorId +when: principal.sub equals document.authorId when: document.status equals 'published' when: principal.record.role equals 'admin' ``` @@ -38,7 +35,7 @@ when: principal.record.role equals 'admin' Returns `true` if the left operand does not equal the right operand. ```yaml -when: principal not equals document.authorId +when: principal.sub not equals document.authorId when: document.status not equals 'archived' ``` @@ -62,6 +59,24 @@ when: document.status not in ['deleted', 'archived'] when: principal.record.plan not in ['free'] ``` +## Null Tests + +### `is null` + +Returns `true` if the field is absent or holds `null`. + +```yaml +when: document.archivedAt is null +``` + +### `is not null` + +Returns `true` if the field holds a value. + +```yaml +when: principal.record.verifiedAt is not null +``` + ## Logical Operators ### `and` @@ -69,7 +84,7 @@ when: principal.record.plan not in ['free'] Returns `true` if both operands are true. ```yaml -when: principal equals document.ownerId and document.status equals 'active' +when: principal.sub equals document.ownerId and document.status equals 'active' ``` ### `or` @@ -77,7 +92,7 @@ when: principal equals document.ownerId and document.status equals 'active' Returns `true` if either operand is true. ```yaml -when: principal.record.role equals 'admin' or principal equals document.ownerId +when: principal.record.role equals 'admin' or principal.sub equals document.ownerId ``` ### `not` @@ -96,10 +111,11 @@ From highest to lowest: 2. `and` 3. `or` (loosest binding) -So `A or B and C` is parsed as `A or (B and C)`. +So `A or B and C` is parsed as `A or (B and C)`. Parentheses override precedence: -There is no parenthesis grouping. Reach for the YAML-list form below when you need to -combine clauses without relying on precedence. +```yaml +when: (document.status equals 'draft' or document.status equals 'review') and principal.sub equals document.authorId +``` ## List Syntax (Implicit AND) @@ -107,14 +123,14 @@ A YAML list of expressions is ANDed together: ```yaml when: - - principal equals document.authorId + - principal.sub equals document.authorId - document.status not in ['deleted'] ``` Is equivalent to: ```yaml -when: principal equals document.authorId and document.status not in ['deleted'] +when: principal.sub equals document.authorId and document.status not in ['deleted'] ``` ## Operands @@ -129,13 +145,14 @@ the names are the author's choice. | Path | Description | |------|-------------| -| `principal` | The bound identity's subject id (the `sub` claim), as a string. A bare reference to the symbol resolves to this. | -| `principal.sub` | Same value as the bare symbol written explicitly. | +| `principal.sub` | The bound identity's subject id (the `sub` claim), as a string. | | `principal.record.fieldName` | A field on the identity's stored record (the identity document, with `_auth` and `password` removed). | | `document.fieldName` | A field on the document the symbol is bound to (the record being read or written). | -There are no built-in roots. Every root referenced here MUST be declared in `given:`, -or generation aborts with `WHEN_UNBOUND_SYMBOL`. `principal` and `document` are +A field reference always has at least one `.`; a bare symbol such as `principal` is +rejected with `WHEN_OPERAND_INVALID`. There are no built-in roots. Every root +referenced here MUST be declared in `given:`, or generation aborts with +`WHEN_UNBOUND_SYMBOL`. `principal` and `document` are conventional names, not keywords -- name your symbols whatever you like in `given:` and reference those names. @@ -148,13 +165,13 @@ form here and do not work. | Type | Syntax | Example | |------|--------|---------| -| Field reference | `document.ownerId` | `principal equals document.ownerId` | +| Field reference | `document.ownerId` | `principal.sub equals document.ownerId` | | String literal | `'admin'` | `document.status equals 'published'` | | List of strings | `['a', 'b']` | `principal.record.role in ['admin', 'moderator']` | ## Edge Cases -- **Keywords are case-insensitive.** `equals`, `not`, `and`, `or`, `in` may be written +- **Keywords are case-insensitive.** `equals`, `not`, `and`, `or`, `in`, `is null` may be written in any case (`EQUALS`, `And`, and so on are all accepted). Lowercase is the conventional, readable form. @@ -162,18 +179,17 @@ form here and do not work. `equals 'admin'`, never `equals "admin"` and never `== 'admin'`. - **There are no boolean literals.** `true` and `false` are not valid operands and - cause a parse error. To test a boolean stored field, compare against a string the - data uses, or model the flag as an enum/string and compare that. + cause `WHEN_OPERAND_INVALID`. To test a boolean stored field, compare against a + string the data uses, or model the flag as an enum/string and compare that. -- **Missing fields resolve to nothing.** If a field path points at a key that is not - present on the record, it resolves to an absent value. Two absent values compare - equal to each other; an absent value never equals a string. There is no `null` - literal you can write yourself -- absence only arises from a missing field. +- **Missing fields resolve to null.** A field path that points at a key not present on + the record resolves to `null`. Two `null` values compare equal; `null` never equals + a string. Test for it with `is null` / `is not null`. - **`not` negates a comparison, not a bare field.** Write `not document.status equals 'archived'`. - A bare field reference (for example `not document.published`) is not a truth test and - does not check whether the field is set; prefer an explicit comparison such as - `document.published equals 'true'` against the value your data stores. + A bare field reference is not a truth test: it never matches, so + `not document.published` always does. Use `is null`, `is not null`, or an explicit + comparison. - **`in []` (empty list).** Always returns `false`; no value is a member of an empty list. **`not in []`** correspondingly always returns `true`. diff --git a/docs/v0.2/Reference/when-expressions/overview.md b/docs/v0.2/Reference/when-expressions/overview.md index d9970a72..b6d9e3db 100644 --- a/docs/v0.2/Reference/when-expressions/overview.md +++ b/docs/v0.2/Reference/when-expressions/overview.md @@ -5,16 +5,13 @@ description: Conditional expressions evaluated against the authenticated princip Conditional expressions evaluated against the authenticated principal and a fetched document inside ABAC access rules. A `when:` expression turns a static role/identity rule into an ownership or attribute check (for example, "the requester is the owner of this record"). -> **Enforcement status** +> **Enforcement** > -> Roles (exact match), `:*` scoping, and ownership/ABAC `when` rules +> Roles, role hierarchies, `:*` scoping, and ownership/ABAC `when` rules > are enforced by the generated FastAPI backend, the generated DRF backend, and the -> `stackform run` dev server alike. A request that fails a rule gets 401 (missing or -> invalid credentials), 403 (authenticated but not allowed), or 404 (record not -> found, checked before ownership). Two current limitations: role hierarchy (for -> example `admin` inheriting `moderator`) is accepted in YAML but not yet applied -> during enforcement -- only exact role matches are checked; and conditional or -> ownership rules require a v0.2 spec, because v0.1 specs do not parse `when` rules. +> `stackform run` dev server alike. A request that no rule admits gets 401 when it +> carries no valid credentials and 403 otherwise; a missing record answers 404 +> before ownership is checked. ## Syntax @@ -87,10 +84,9 @@ The names are the author's choice -- substitute whatever you declared in `given: | Path | Description | |------|-------------| -| `principal` | Bare reference (no `.`) resolves to the bound identity's primary-key value (its `sub`). | | `principal.sub` | The identity's primary-key value (string). | | `principal.identity_collection` | The name of the identity's collection. | -| `principal.roles` | The identity's role list (loaded from the auth record's `roles`/`role` field). | +| `principal.roles` | The identity's roles: the `auth.roles` field, or the managed `_auth.roles`. | | `principal.record.fieldName` | Any field on the identity's stored record. Stored fields live under `record`, not directly on the symbol. | | `document.fieldName` | A field on the fetched document the symbol is bound to. | @@ -145,8 +141,8 @@ choice, exactly like `principal` and `document`. ### Reading parts in `when:` -Access on the bound symbol is **part-qualified**: you always name the part, then -the key. +A symbol bound to the whole request (`request` or `request:`) is +**part-qualified**: you name the part, then the key. ```yaml when: req.body.role equals 'admin' @@ -156,6 +152,9 @@ when: req.header.authorization not equals '' when: req.cookie.session not equals '' ``` +A symbol bound to one part holds that part, so you name the key directly: +`member.role` for `member: request.body`, `org.orgId` for `org: request.path`. + The binding value in `given:` is only the binding itself (`request`, `request.body`, `request:createMembership`). Part-qualified leaf access such as `req.body.role` lives in `when:`, never in `given:`. Writing `request.body.role` @@ -168,7 +167,8 @@ the body), and on `request.body`, `request.query`, `request.path`. It is rejected on a single leaf and on `header` or `cookie` (which are not typeable), failing generation with `REQUEST_TYPE_ON_LEAF`. -`request.path.` must name a `{name}` present in that route's composed path +A path parameter read in `when:` (`req.path.`, or `org.` for +`org: request.path`) must name a `{name}` present in that route's composed path template (the existing `{id}` of CRUD routes, or an explicit route such as `/{orgId}/members`). An undeclared name fails with `REQUEST_PATH_PARAM_NOT_DECLARED`. @@ -179,8 +179,8 @@ Request resolution is fail-safe: a missing leaf resolves to `null`, it never raises. `req.body.x` where the body has no `x` is `null`, the same as a missing principal or document field. -A typed body is validated **before** authorization. If the body does not match -the bound DTO or entity, the request returns 422 and the rule never runs. The +A typed body is validated against the bound DTO or entity: a request the rules +admit whose body does not match returns 422. The parts are sourced as follows: `body` from the parsed JSON body, `path` from the route's URL parameters, `query` from the query string, `header` from the request headers (lowercased keys), and `cookie` from the request cookies. @@ -274,7 +274,7 @@ Non-owners are denied. The owner falls through to the trailing `then: allow`. - **Missing document field.** `document.X` where `X` does not exist resolves to `null`. -- **Precedence.** `not` binds tightest, then `and`, then `or`. So `A or B and C` means `A or (B and C)`. There is no parenthesis grouping; split complex logic into the list form instead. +- **Precedence.** `not` binds tightest, then `and`, then `or`. So `A or B and C` means `A or (B and C)`. Parentheses override it: `(A or B) and C`. - **`principal.sub` type.** Always a string (the principal's primary-key value, stringified). diff --git a/docs/v0.2/Tutorial/02-field-types.md b/docs/v0.2/Tutorial/02-field-types.md index 2ccafa87..ec90945f 100644 --- a/docs/v0.2/Tutorial/02-field-types.md +++ b/docs/v0.2/Tutorial/02-field-types.md @@ -70,7 +70,7 @@ With `basePath: /api` and `version: v1`, the notes routes live under | `boolean` | True/false | `default`, `readonly` | | `datetime` | ISO 8601 timestamp string | `required`, `default`, `readonly` | | `enum` | One of a fixed set of values | `values: [...]` required; `default` optional | -| `array` | List of items | `items:` sub-schema (required) | +| `array` | List of items | `items:` sub-schema (required), with its type and that type's constraints | | `object` | Free-form JSON object | `properties:` (optional typed shape), `additionalProperties` | | `ref` | String id pointing at another entity | `ref: entityName` required | @@ -78,13 +78,11 @@ With `basePath: /api` and `version: v1`, the notes routes live under - `required: true` -- field must be present on create (and on replace). On update, every field is optional. -- `default: value` -- declared on the generated model, but applied only when the - client actually sends the field. Bodies are persisted with - `model_dump(exclude_unset=True)`, so a field you omit is NOT written, even if it - has a default. Send the field explicitly if you want the default value stored. -- `readonly: true` -- the field is dropped from create/update/replace request models - entirely. Clients cannot set it; it is populated server-side or left unset. Good - for audit columns like `createdAt`. +- `default: value` -- stored on create and replace when the client omits the + field. A required field with a default may be omitted. +- `readonly: true` -- clients may not send the field; a write body that carries it + is rejected with 422. Its default, if any, is still stored on create. Good for + audit columns like `createdAt`. - `unique: true` -- creates a SQL unique constraint or MongoDB unique partial index. A duplicate write is rejected with `409 Conflict`; the response names the field when the datastore supplies it without exposing the conflicting value. @@ -98,18 +96,13 @@ document is stored): |--------|------------|--------| | `min` | `number` | value must be `>= min` | | `max` | `number` | value must be `<= max` | -| `minLength` | `string` (and `ref`) | string length must be `>= minLength` | -| `maxLength` | `string` (and `ref`) | string length must be `<= maxLength` | +| `minLength` | `string` | string length must be `>= minLength` | +| `maxLength` | `string` | string length must be `<= maxLength` | | `values` | `enum` | value must be one of the listed values | +| `format` | `string` | value must be a valid `email`, `uri`, `e164`, `uuidv4` or `uuidv7` | -The following is accepted in the schema but is metadata-only -- declarative, -surfaced to the UI, and never enforced on write: - -- `format: email | uri | e164` on a string -- a hint, not a validator. The generated - backend does not check the value matches the format. - -The lifecycle below shows where a single field value is checked, accepted as -metadata, or stripped during a create request: +The lifecycle below shows where a single field value is checked during a create +request: ```mermaid flowchart TD @@ -117,14 +110,13 @@ flowchart TD B -- No --> R1[Reject: Unknown fields] B -- Yes --> C{Type and min/max/length OK?} C -- No --> R2[Reject: validation error] - C -- Yes --> D{Required and missing?} + C -- Yes --> D{Required, missing and no default?} D -- Yes --> R3[Reject: Missing required fields] - D -- No --> E[format: accepted metadata] - E --> U{unique constraint satisfied?} + D -- No --> U{unique constraint satisfied?} U -- No --> R4[Reject: 409 Conflict] U -- Yes --> F{Value sent by client?} F -- Yes --> S1[Stored] - F -- No, has default --> S2[Stripped by exclude_unset: default not persisted] + F -- No, has default --> S2[Default stored] F -- No, type id --> S3[Server-generated id] ``` @@ -145,17 +137,17 @@ entity/repository: authorId: { type: ref, ref: author, required: true } ``` -The value is stored and validated only as a string -- there is no UUID-format check, -no foreign-key integrity, and the target entity is NOT verified to exist at -generation time. A typo in `ref:` is not caught when you build; the field simply -holds whatever string you send. Relationships are covered in tutorial 03. +The value is stored and validated as a string, with no foreign-key integrity. The +target must be a declared entity; a typo in `ref:` fails generation with +`REF_TARGET_NOT_FOUND`. Relationships are covered in tutorial 03. ## Typed objects and arrays -`object` accepts free-form JSON by default. To document an inner shape, add -`properties:` (each value is itself a field config) and optionally -`additionalProperties: false`. `array` requires an `items:` sub-schema; the item can -be any field type, including an `object`: +`object` accepts free-form JSON by default. To check an inner shape, add +`properties:` (each value is itself a field config); keys outside `properties` are +then rejected unless `additionalProperties: true`. `array` requires an `items:` +sub-schema that every element must satisfy; the item can be any field type, +including an `object`: ```yaml metadata: diff --git a/docs/v0.2/Tutorial/04-authentication.md b/docs/v0.2/Tutorial/04-authentication.md index 520b001c..bf7fdfca 100644 --- a/docs/v0.2/Tutorial/04-authentication.md +++ b/docs/v0.2/Tutorial/04-authentication.md @@ -102,7 +102,7 @@ A JWT (JSON Web Token) is a signed string that encodes the user's identity. The ## Routes are protected by default -As soon as any `type: auth` entity exists in the spec, every route that is not explicitly `access: public` requires a valid principal (a resolved, authenticated user). A request without valid credentials to a protected route gets `401 Unauthorized`. This is why the controllers above set `access: public` on the routes that should stay open (including `register` and `login`). +Every route that is not explicitly `access: public` requires a valid principal (a resolved, authenticated user). A request without valid credentials to a protected route gets `401 Unauthorized`. This is why the controllers above set `access: public` on the routes that should stay open (including `register` and `login`). A minified controller (one declared with a bare `service:` and no `access:`) defaults to `access: public`. To protect a resource you must opt in with `access: "*"` (or a more specific rule). Locking down routes is the subject of [Tutorial 05: Access Control](./05-access-control.md). @@ -131,7 +131,7 @@ curl -X POST http://127.0.0.1:8000/api/v1/users/login \ -d '{"email": "alice@example.com", "password": "s3cr3t"}' ``` -A successful login returns the token plus the user record, with `password`, `_id`, and the `_auth` namespace stripped: +A successful login returns the token, the user record with `password`, `apiKey`, `_id`, and the `_auth` namespace stripped, and the roles the record holds: ```json { @@ -140,7 +140,8 @@ A successful login returns the token plus the user record, with `password`, `_id "id": "0192...", "email": "alice@example.com", "displayName": "Alice" - } + }, + "roles": [] } ``` @@ -182,6 +183,6 @@ The generated backend reads these at runtime: - Register and login routes are off by default; enable them with `api.operations.auth: { register: true, login: true }`. - Passwords are hashed with PBKDF2-SHA256 and stored at `_auth.passwordHash`. The raw password is never stored, and `_auth` is stripped from responses. - The `password` field on register is implicit -- declaring `password` or `passwordHash` in `fields:` is a build error. -- Login returns `{"token": "...", "user": {...}}`; the JWT carries identity only (`sub`, `identity_collection`), and roles are read from the user record per request. +- Login returns `{"token": "...", "user": {...}, "roles": [...]}`; the JWT carries identity only (`sub`, `identity_collection`), and roles are read from the user record per request. - A client can authenticate with a Bearer JWT, an API key, or HTTP Basic credentials. - Once an auth entity exists, every non-public route requires a valid principal (401 otherwise). diff --git a/docs/v0.2/Tutorial/05-access-control.md b/docs/v0.2/Tutorial/05-access-control.md index a5a9effd..1fcba6e7 100644 --- a/docs/v0.2/Tutorial/05-access-control.md +++ b/docs/v0.2/Tutorial/05-access-control.md @@ -5,16 +5,13 @@ description: Protecting routes with public, "*" (any principal), entity scoping, **Concept introduced:** Controller-level and per-operation `access:` rules: `public`, `"*"` (any authenticated principal), `:*` scoping, and per-operation overrides. -> **Enforcement status** +> **Enforcement** > -> Roles (exact match), `:*` scoping, and ownership/ABAC `when` rules +> Roles, role hierarchies, `:*` scoping, and ownership/ABAC `when` rules > are enforced by the generated FastAPI backend, the generated DRF backend, and the -> `stackform run` dev server alike. A request that fails a rule gets 401 (missing or -> invalid credentials), 403 (authenticated but not allowed), or 404 (record not -> found, checked before ownership). Two current limitations: role hierarchy (for -> example `admin` inheriting `moderator`) is accepted in YAML but not yet applied -> during enforcement -- only exact role matches are checked; and conditional or -> ownership rules require a v0.2 spec, because v0.1 specs do not parse `when` rules. +> `stackform run` dev server alike. A request that no rule admits gets 401 when it +> carries no valid credentials and 403 otherwise; a missing record answers 404 +> before ownership is checked. ## Restricting write operations diff --git a/docs/v0.2/Tutorial/06-roles.md b/docs/v0.2/Tutorial/06-roles.md index 2643e4bd..d35173b2 100644 --- a/docs/v0.2/Tutorial/06-roles.md +++ b/docs/v0.2/Tutorial/06-roles.md @@ -7,9 +7,11 @@ description: Declaring roles with an enum field and using them in role-based acc ## Declaring roles -Roles are not a standalone block. A role is an ordinary `enum` field on the auth -entity, and you point `auth.roles` at that field's name. The field's `values` list -defines the valid roles and its `default` decides the role a new record gets. +Here a role is an ordinary `enum` field on the auth entity, and you point +`auth.roles` at that field's name. The field's `values` list defines the valid roles +and its `default` decides the role a new record gets. An auth entity may instead keep +its roles out of its fields with a managed `roles:` block; see +[Roles](../Reference/roles/overview.md). ```yaml version: "0.2" @@ -90,16 +92,13 @@ The `role` field must be of type `enum`, and its name must match `auth.roles`. A mismatch fails generation with `AUTH_ROLES_FIELD_NOT_FOUND`; pointing `auth.roles` at a non-enum field fails with `AUTH_ROLES_FIELD_NOT_ENUM`. -> **Enforcement status** +> **Enforcement** > -> Roles (exact match), `:*` scoping, and ownership/ABAC `when` rules +> Roles, role hierarchies, `:*` scoping, and ownership/ABAC `when` rules > are enforced by the generated FastAPI backend, the generated DRF backend, and the -> `stackform run` dev server alike. A request that fails a rule gets 401 (missing or -> invalid credentials), 403 (authenticated but not allowed), or 404 (record not -> found, checked before ownership). Two current limitations: role hierarchy (for -> example `admin` inheriting `moderator`) is accepted in YAML but not yet applied -> during enforcement -- only exact role matches are checked; and conditional or -> ownership rules require a v0.2 spec, because v0.1 specs do not parse `when` rules. +> `stackform run` dev server alike. A request that no rule admits gets 401 when it +> carries no valid credentials and 403 otherwise; a missing record answers 404 +> before ownership is checked. ## How a role reaches an access decision @@ -118,7 +117,7 @@ sequenceDiagram S->>DB: Load record by sub DB-->>S: User document S->>S: Read role from the auth.roles enum field - S->>S: Evaluate access rule (exact role match) + S->>S: Evaluate access rule (role and the roles it includes) alt role matches S-->>C: 200 OK else missing or invalid credentials @@ -130,7 +129,7 @@ sequenceDiagram ## Using a role in an access rule -The access value `entity:role` requires a principal of that entity holding that exact +The access value `entity:role` requires a principal of that entity holding that role. In the example, `access: user:moderator` allows any `user` whose `role` is `moderator`. @@ -144,7 +143,7 @@ Other access values you can combine with roles: - `public` -- no authentication. - `"*"` -- any authenticated principal, role ignored. - `user:*` -- any `user`, role ignored. -- `user:admin` -- a `user` whose role is exactly `admin`. +- `user:admin` -- a `user` holding `admin`, directly or through the hierarchy. ### Single auth entity: prefix is optional @@ -161,46 +160,56 @@ cannot tell which entity's `admin` you mean; write `user:admin` (or `apiClient:a to disambiguate. Using a bare role when there is no auth entity at all fails with `ACCESS_NO_AUTH_ENTITY`. -## Role hierarchy is not applied yet +## Role hierarchy -You may see a top-level `roles:` map (keyed by auth-entity name) that declares a -hierarchy. It is accepted by the parser but **not applied during enforcement** in any -target today: only exact role matches are checked. An `admin` user does NOT -automatically satisfy `access: user:moderator`. Do not rely on inheritance. +A `roles:` block on the auth entity can declare which roles include others. Repeat +the enum's values and default there, then add the `hierarchy`: ```yaml -# Parsed, but inert during access enforcement today. -roles: +entities: user: - admin: [moderator] - moderator: [user] + type: auth + auth: + identifier: email + via: password + roles: role + roles: + values: [admin, moderator, user] + default: user + hierarchy: + admin: [moderator] + moderator: [user] ``` -If you need "admin OR owner" style logic, express it with an access rule list and a -`when` ownership condition rather than a hierarchy. See Tutorial 07 (ABAC rules). +An `admin` now satisfies `access: user:moderator` and `access: user:user`, so each rule +names the lowest role allowed. See [Role hierarchy](../Reference/roles/hierarchy.md). + +For "admin OR owner" style logic, combine a role rule with a `when` ownership +condition in an access rule list. See Tutorial 07 (ABAC rules). ## Setting and changing a role -A new record gets the role from the enum field's `default:` on register. There is no -built-in endpoint dedicated to changing a role: to promote a user, update the `role` -field on the record (through an `update`/`replace` route you have authorized, or -directly in MongoDB). +A new record gets the role from the enum field's `default:` on register. To promote +a user, update the `role` field through an `update`/`replace` route, or directly in +MongoDB. -**Watch out:** the role field is a normal field on the document. In the generated -FastAPI backend it is accepted on write and returned in responses, so anyone allowed -to update a user could set their own role unless you gate the write. Restrict the -`update`/`replace` operations (for example `access: user:admin`) on the users -controller if self-service role changes are a concern. +A caller sets the role field only to a role it holds itself, counting the roles its +hierarchy includes. A create that names a role the caller does not hold stores the +default role instead, and an update that does so leaves the role unchanged. So a +public `register` always yields `user`, and only an `admin` can make another user an +`admin`. ## Key points -- A role is an `enum` field; `auth.roles` names that field. There is no standalone - `roles:` block on the entity (any such block is ignored). +- A role is an `enum` field named by `auth.roles`, or a managed role declared in a + `roles:` block. - The enum `values` define valid roles; the enum `default` sets the role on register. +- A caller grants only roles it holds. - Roles are loaded from the database on every request, so role changes apply immediately without re-login. - Use roles in access rules as `entity:role` (or a bare role when there is one auth entity); `entity:*` matches the entity regardless of role. -- Role hierarchy is parsed but inert; only exact role matches are enforced today. +- `roles.hierarchy` lets a role include others, so a higher role passes every rule + for the roles it includes. - Roles are per-entity: different auth entities can define completely different role sets, each in its own enum field. diff --git a/docs/v0.2/Tutorial/07-abac-rules.md b/docs/v0.2/Tutorial/07-abac-rules.md index 78ad6c60..a837fdee 100644 --- a/docs/v0.2/Tutorial/07-abac-rules.md +++ b/docs/v0.2/Tutorial/07-abac-rules.md @@ -9,22 +9,19 @@ ABAC is access control based on attributes of the request, the authenticated use A key concept used throughout ABAC rules is `principal.sub`. The `sub` (short for "subject") is the authenticated user's primary-key value -- their `id`. There are no built-in root symbols: `principal` is just the conventional name for the identity you bind in the rule's `given:` block, and `.sub` is the main way to compare "who is making this request" against "who owns this document." Every root a `when:` references must be declared in `given:`, or generation aborts with `WHEN_UNBOUND_SYMBOL`. -> **Enforcement status** +> **Enforcement** > -> Roles (exact match), `:*` scoping, and ownership/ABAC `when` rules +> Roles, role hierarchies, `:*` scoping, and ownership/ABAC `when` rules > are enforced by the generated FastAPI backend, the generated DRF backend, and the -> `stackform run` dev server alike. A request that fails a rule gets 401 (missing or -> invalid credentials), 403 (authenticated but not allowed), or 404 (record not -> found, checked before ownership). Two current limitations: role hierarchy (for -> example `admin` inheriting `moderator`) is accepted in YAML but not yet applied -> during enforcement -- only exact role matches are checked; and conditional or -> ownership rules require a v0.2 spec, because v0.1 specs do not parse `when` rules. +> `stackform run` dev server alike. A request that no rule admits gets 401 when it +> carries no valid credentials and 403 otherwise; a missing record answers 404 +> before ownership is checked. ## Authors can edit their own notes Role-based rules cover broad access (e.g., all moderators can edit any note), but they cannot express "only the author can edit their own note." ABAC rules solve this by comparing attributes of the authenticated user against attributes of the document being accessed. -Roles come from an enum field referenced by `auth.roles`. The standalone entity-level `roles:` block (with `values`/`default`/`hierarchy`) is not wired in v0.2 -- declare an `enum` field and point `auth.roles` at it instead. +Roles come from an enum field referenced by `auth.roles`, as in Tutorial 06. ```yaml version: "0.2" @@ -32,6 +29,10 @@ version: "0.2" api: basePath: /api version: v1 + operations: + auth: + register: true + login: true mongo: uri: mongodb://localhost:27017 @@ -105,7 +106,7 @@ With `api: { basePath: /api, version: v1 }`, the routes are `PATCH /api/v1/notes Two notes about this spec: -- `register` and `login` are not generated unless you enable them. The minified `users` controller above lists them under `operations:`, which generates `POST /api/v1/users/register` and `POST /api/v1/users/login`. +- `register` and `login` are generated only when `api.operations.auth` enables them, as `POST /api/v1/users/register` and `POST /api/v1/users/login`. Their entries under the `users` controller's `operations:` set their access; naming an operation that is not enabled fails generation with `DESUGAR_OPERATION_NOT_EXPOSED`. - A minified controller defaults to `access: public`. Setting `access: public` here is explicit; the per-operation rules on `create`/`update`/`delete` override it for those operations. ## How given/when/then works @@ -114,7 +115,7 @@ Each item in the `access:` list is evaluated top-to-bottom. The first matching r ```yaml access: - - moderator # rule 1: allow moderators (exact role match) + - moderator # rule 1: allow moderators - given: principal: identity:user # bind the authenticated user under "principal" document: notes # load the target document under "document" @@ -174,7 +175,7 @@ Operands and references: - `principal.record.fieldName` -- any stored field on the authenticated user's record. Use `principal.record.`, not `principal.`. - `document.fieldName` -- any field on the document being accessed. -Operators: `equals`, `not equals`, `in`, `not in`, `and`, `or`, `not`. The right-hand side of a comparison is either another field reference or a single-quoted string. Use the words (`equals`), not symbols (`==`). String values must be wrapped in single quotes; `in`/`not in` take a bracketed list of single-quoted strings. There is no parenthesis grouping; combine conditions with `and`/`or`/`not`. +Operators: `equals`, `not equals`, `in`, `not in`, `is null`, `is not null`, `and`, `or`, `not`. The right-hand side of a comparison is either another field reference or a single-quoted string. Use the words (`equals`), not symbols (`==`). String values must be wrapped in single quotes; `in`/`not in` take a bracketed list of single-quoted strings. Combine conditions with `and`/`or`/`not` and group them with parentheses; `is null` and `is not null` test for a missing value. A `when:` value may also be a YAML list of expressions, which are AND-ed together: @@ -186,9 +187,18 @@ when: **Note:** On `create` operations there is no existing document yet, so rules with `document:` bindings cannot run a post-fetch check. Use principal-only rules (role checks, `"*"`) to control create access. The example above guards `create` with `access: "*"` (any authenticated user). -## Accepted but not yet enforced +## Refusal reasons + +A rule object may carry a `reason:`. When a `then: deny` rule decides, its `reason` is the detail of the 403 response. When no rule matches, the detail is the `reason` of the first allow rule that has one, or `Forbidden`. -The optional `filter:`, `writeFields:`, and `reason:` keys on a rule object are parsed and validated, but do not change enforcement today. Treat them as forward-looking metadata; access is decided solely by principal match, role match, and the `when` condition. +```yaml +access: + - given: { document: notes } + when: document.status in ['archived'] + then: deny + reason: Archived notes are read-only + - moderator +``` ## Key points @@ -196,4 +206,4 @@ The optional `filter:`, `writeFields:`, and `reason:` keys on a rule object are - `document:` in `given:` references the repository (collection) name (`notes`), not the entity name. - `given:` symbol names are arbitrary; `principal`/`document` are conventions, not reserved words. - `then: deny` combined with a fallback `then: allow` is a common pattern for inverse logic. -- Roles are an enum field selected by `auth.roles`; only exact role matches are enforced (hierarchy is inert today). +- Roles are an enum field selected by `auth.roles`; a `roles.hierarchy` lets a role include others (Tutorial 06). diff --git a/docs/v0.2/Tutorial/08-entity-inheritance.md b/docs/v0.2/Tutorial/08-entity-inheritance.md index 2207ad05..22e8b55b 100644 --- a/docs/v0.2/Tutorial/08-entity-inheritance.md +++ b/docs/v0.2/Tutorial/08-entity-inheritance.md @@ -95,18 +95,15 @@ controllers: when: principal.sub equals document.authorId ``` -The `user` entity gets its roles from the enum `role` field, wired with `auth.roles: role`. This is the only role mechanism the v0.2 transformer reads. A standalone entity-level `roles: { values, default, hierarchy }` block is accepted by the schema but silently ignored; do not rely on it. +The `user` entity gets its roles from the enum `role` field, wired with `auth.roles: role`. See [Roles](../Reference/roles/overview.md) for the managed `roles:` block alternative. -> **Enforcement status** +> **Enforcement** > -> Roles (exact match), `:*` scoping, and ownership/ABAC `when` rules +> Roles, role hierarchies, `:*` scoping, and ownership/ABAC `when` rules > are enforced by the generated FastAPI backend, the generated DRF backend, and the -> `stackform run` dev server alike. A request that fails a rule gets 401 (missing or -> invalid credentials), 403 (authenticated but not allowed), or 404 (record not -> found, checked before ownership). Two current limitations: role hierarchy (for -> example `admin` inheriting `moderator`) is accepted in YAML but not yet applied -> during enforcement -- only exact role matches are checked; and conditional or -> ownership rules require a v0.2 spec, because v0.1 specs do not parse `when` rules. +> `stackform run` dev server alike. A request that no rule admits gets 401 when it +> carries no valid credentials and 403 otherwise; a missing record answers 404 +> before ownership is checked. ## How inheritance works @@ -168,8 +165,8 @@ When `pk:` is declared, the entity's id field (if any) is simply not processed - ## Key points - `abstract: true` prevents a collection from being created for the base entity. -- Only a repository binding (`repositories..entity`) to an abstract entity is checked (`DESUGAR_ABSTRACT_REFERENCED`); a `ref:` field pointing at an abstract entity is not validated, since plain `ref:` targets are never checked at generation time. +- Only a repository binding (`repositories..entity`) rejects an abstract entity (`DESUGAR_ABSTRACT_REFERENCED`); a `ref:` field may point at an abstract entity, though it must name a declared one (`REF_TARGET_NOT_FOUND`). - `extends:` accepts only one parent. There is no multiple inheritance, but chains of abstract entities are allowed. - Inheritance also copies `type:` and `auth:` config from the parent, if present. A child can override these. -- Roles come from an enum field wired with `auth.roles: `, not from a standalone entity-level `roles:` block (which is inert). +- Roles come from an enum field wired with `auth.roles: `, or from a managed `roles:` block on the auth entity. - `pk:` lets you use a natural key (like a country code) instead of an auto-generated UUID; the client supplies it on create. diff --git a/docs/v0.2/Tutorial/09-custom-operations.md b/docs/v0.2/Tutorial/09-custom-operations.md index d0bee989..0ac6bdaa 100644 --- a/docs/v0.2/Tutorial/09-custom-operations.md +++ b/docs/v0.2/Tutorial/09-custom-operations.md @@ -1,6 +1,6 @@ --- title: "Tutorial 09: Custom Operations" -description: Custom operations with handler functions (e.g. publishing a note), wiring imports, and the two handler contracts. +description: Custom operations with handler functions (e.g. publishing a note), wiring imports, and the handler contract. --- **Concept introduced:** Custom operations backed by handler functions (for example, publishing a note) that do not map to plain create/update/delete. @@ -136,89 +136,41 @@ Notes on this spec: - `register`/`login` routes are not generated unless you enable them with `api.operations.auth`, as shown above. - `access: "*"` means any logged-in user; `public` means no auth; `moderator` is an exact role match; the `given`/`when` rule is an ownership check. -## Two handler contracts +## Handler contract -The handler signature depends on the target you generate or run. Write the version that matches how you ship the app. - -### Generated backends: request object - -`stackform build myapp.yaml backend` emits a route that awaits the handler with -the target's request object. FastAPI supplies `fastapi.Request`, DRF supplies its -request, and raw-ASGI supplies a request-compatible object with `path_params`, -`body`, `headers`, and `state`. Read inputs from that object and return a -JSON-serializable value (or a DRF `Response` in the DRF target). +`stackform run` and every generated backend (FastAPI, DRF, raw ASGI) call the +handler the same way: with one request object, sending its return value as the +JSON response body. ```python # myapp/handlers/notes.py from datetime import datetime, timezone -from fastapi import HTTPException, Request - -from repositories.notes import NotesRepository -from dependencies.mongodb import get_database - - -async def publish(request: Request): - note_id = request.path_params["id"] - repo = NotesRepository(get_database()) - doc = await repo.get(note_id) - if doc is None: - raise HTTPException(status_code=404, detail="Note not found") - if doc["published"]: - raise HTTPException(status_code=409, detail="Already published") - return await repo.update( - note_id, - {"published": True, "publishedAt": datetime.now(timezone.utc).isoformat()}, - ) -``` - -Raise `HTTPException` for controlled error responses. Any other uncaught exception becomes a 500. The custom-op route on the FastAPI backend does not run the CRUD access pipeline, so if the action must be restricted, enforce it inside the handler (resolve the principal and check it yourself). -### `stackform run` dev server: `context: RequestContext` - -The in-process dev server invokes the handler as a pipeline step. It receives and returns a `RequestContext`: read inputs from the context, set `context.response`, and return the context. - -```python -# myapp/handlers/notes.py (dev-server variant) -from datetime import datetime, timezone - -from yaml_api_generator.server.common.steps import NotFoundError - - -async def publish(context): - note_id = context.url_params["id"] - doc = await context.repository.get(note_id) - if doc is None: - raise NotFoundError("Note not found") - if doc["published"]: - raise ValueError("Already published") - context.response = await context.repository.update( - note_id, - {"published": True, "publishedAt": datetime.now(timezone.utc).isoformat()}, - ) - return context +async def publish(request): + if request.body.get("draft"): + raise ValueError("Cannot publish a draft") + return { + "id": request.params["id"], + "published": True, + "publishedAt": datetime.now(timezone.utc).isoformat(), + "by": request.principal["sub"], + } ``` -`RequestContext` fields available to a handler: - -- `context.repository` -- the repository bound to this service's collection. -- `context.url_params` -- path parameters, for example `context.url_params["id"]`. -- `context.body` -- the parsed JSON body, or `None`. -- `context.headers` -- the request headers. -- `context.principal` -- a dict for the authenticated user (or `None` on public routes): `principal["sub"]`, `principal["identity_collection"]`, `principal["record"]` (the user document with `_auth` and `password` stripped), and `principal["roles"]` when a roles field is configured. It is a plain dict, not an object with attributes. - -There is no `context.now()`; build timestamps yourself. Raise `NotFoundError` (imported from `yaml_api_generator.server.common.steps`) for a 404, or a plain `ValueError` for a 422. Under `run`, a non-public custom route resolves the principal and applies its access rules before your handler runs. +The request carries `params` (path parameters), `query`, `headers`, `cookies`, +`body` (the JSON body, or `{}`), `principal` (a plain dict, or `None` on public +routes) and `inputs` (the service's `with:` values). The handler owns its data +access. Raise `ValueError` for a 422; any other exception answers 500. The route's +access rules run before the handler. -> **Enforcement status** +> **Enforcement** > -> Roles (exact match), `:*` scoping, and ownership/ABAC `when` rules +> Roles, role hierarchies, `:*` scoping, and ownership/ABAC `when` rules > are enforced by the generated FastAPI backend, the generated DRF backend, and the -> `stackform run` dev server alike. A request that fails a rule gets 401 (missing or -> invalid credentials), 403 (authenticated but not allowed), or 404 (record not -> found, checked before ownership). Two current limitations: role hierarchy (for -> example `admin` inheriting `moderator`) is accepted in YAML but not yet applied -> during enforcement -- only exact role matches are checked; and conditional or -> ownership rules require a v0.2 spec, because v0.1 specs do not parse `when` rules. +> `stackform run` dev server alike. A request that no rule admits gets 401 when it +> carries no valid credentials and 403 otherwise; a missing record answers 404 +> before ownership is checked. ## Declarative alternative: repository query operations @@ -235,9 +187,9 @@ repositories: filter: { published: true } ``` -Supported `query.command` values are `find`, `findOne`, `aggregate`, `countDocuments`, and `estimatedDocumentCount`. `find`/`findOne`/`countDocuments` require a `filter`; `aggregate` requires a `pipeline`. A repository with no `entity` may set `query.collection` to target a collection explicitly (otherwise `QUERY_MISSING_COLLECTION`). A repository-level operation that has neither `query` nor `handler` fails with `OPERATION_MISSING_QUERY_OR_HANDLER`. +Supported `query.command` values are `find`, `findOne`, `aggregate`, `countDocuments`, and `estimatedDocumentCount`. `find`/`findOne`/`countDocuments` require a `filter`; `aggregate` requires a `pipeline`. A repository with no `entity` may set `query.collection` to target a collection explicitly (otherwise `QUERY_MISSING_COLLECTION`). A repository-level operation declares exactly one of `query` or `handler`; neither or both fails schema validation (`SCHEMA_VALIDATION_ERROR`). -A repository `operations..handler` is a second handler kind: it is invoked as `await (**kwargs)` with the call's arguments, rather than receiving a `Request` or `context`. Use the service/controller handler (above) for HTTP-shaped operations and the repository handler for data-access building blocks. +A repository `operations..handler` receives the same request object as a service handler. ## What the custom operation generates @@ -253,11 +205,11 @@ Custom-operation routes are emitted by the FastAPI, DRF, and raw-ASGI targets. - `handler:` on a service operation points to `.`, resolved through `imports:`. - Custom operations require `method` and `path` on the controller operation; there is no inference. -- Generated targets pass their request object; `stackform run` passes a `RequestContext`. +- Every target and `stackform run` pass the handler the same request object. - For a plain parameterized read, prefer a repository `query` operation over a handler. -- Custom operations participate in the same access vocabulary as CRUD; under `run` and `drf` the rules are enforced for custom routes, while the FastAPI custom route leaves enforcement to your handler. +- Custom operations use the same access vocabulary as CRUD, and every target (`run`, FastAPI, raw ASGI and DRF) enforces the rules before your handler runs. A bare `document:` binding loads the record named by the route's `{id}` parameter. ## See Also -- [Handler Context Reference](../Reference/services/handler-context.md) -- the `RequestContext` object and the `run` dev-server handler contract. -- [Custom operations reference](../Reference/services/custom-operations.md) -- the FastAPI/raw `Request` handler contract and import resolution. +- [Handler Context Reference](../Reference/services/handler-context.md) -- every attribute of the handler's request object. +- [Custom operations reference](../Reference/services/custom-operations.md) -- custom operation routes and import resolution. diff --git a/docs/v0.2/Tutorial/10-external-auth.md b/docs/v0.2/Tutorial/10-external-auth.md index de0be27f..9895d204 100644 --- a/docs/v0.2/Tutorial/10-external-auth.md +++ b/docs/v0.2/Tutorial/10-external-auth.md @@ -1,14 +1,14 @@ --- title: "Tutorial 10: External Auth Providers" -description: Add an external OIDC provider (Google) alongside password login using auth.bindings. +description: Add an external OIDC provider (Google) alongside password login with a binding object in auth.via. --- -**Concept introduced:** Adding an external OIDC provider alongside password login via `auth.bindings`. +**Concept introduced:** Adding an external OIDC provider alongside password login with a binding object in `auth.via`. -Password login is configured through `auth.via`. External identity providers (OIDC, -SAML) are configured separately: declare the provider once under the top-level -`providers:` block, then attach it to an auth entity through `auth.bindings`. Each -binding generates its own login flow that ends in the same app-issued JWT, so your +Every sign-in method of an auth entity is listed in `auth.via`. External identity +providers (OIDC, SAML) are declared once under the top-level `providers:` block, then +attached to an auth entity by a binding object in `auth.via`. Each binding generates +its own login flow that ends in the same app-issued JWT, so your access rules work identically no matter how the user signed in. ## Adding Google OIDC to the user entity @@ -47,15 +47,15 @@ entities: type: auth auth: identifier: email - via: password - roles: role - bindings: + via: + - provider: password - provider: google identifier: email claim_mapping: email: email displayName: name auto_provision: true + roles: role fields: email: { type: string, required: true, unique: true } displayName: { type: string, required: true } @@ -107,14 +107,14 @@ controllers: - given: principal: identity:user document: notes - when: principal equals document.authorId + when: principal.sub equals document.authorId delete: access: - moderator - given: principal: identity:user document: notes - when: principal equals document.authorId + when: principal.sub equals document.authorId publish: method: POST path: "/{id}/publish" @@ -122,7 +122,7 @@ controllers: - given: principal: identity:user document: notes - when: principal equals document.authorId + when: principal.sub equals document.authorId ``` What to notice in this spec: @@ -131,23 +131,19 @@ What to notice in this spec: generated backend reads the provider's `authorization_endpoint`, `token_endpoint`, and `userinfo_endpoint` from that document. There is no built-in discovery for "well-known" providers; always supply the URL. -- `auth.via: password` keeps password login. The Google provider is attached through - `auth.bindings`, NOT through `via`. `via` is only for the built-in `password` and - `apiKey` methods. +- `auth.via` lists `password`, which keeps password login, and a binding object for + the Google provider. - `claim_mapping` and `auto_provision` live INSIDE each binding, not on the entity. -- Roles come from an enum field named by `auth.roles: role`. A standalone - entity-level `roles:` block is silently ignored in v0.2; see Tutorial 06. +- Roles come from an enum field named by `auth.roles: role`; see Tutorial 06. A + provisioned user gets the field's default role. -> **Enforcement status** +> **Enforcement** > -> Roles (exact match), `:*` scoping, and ownership/ABAC `when` rules +> Roles, role hierarchies, `:*` scoping, and ownership/ABAC `when` rules > are enforced by the generated FastAPI backend, the generated DRF backend, and the -> `stackform run` dev server alike. A request that fails a rule gets 401 (missing or -> invalid credentials), 403 (authenticated but not allowed), or 404 (record not -> found, checked before ownership). Two current limitations: role hierarchy (for -> example `admin` inheriting `moderator`) is accepted in YAML but not yet applied -> during enforcement -- only exact role matches are checked; and conditional or -> ownership rules require a v0.2 spec, because v0.1 specs do not parse `when` rules. +> `stackform run` dev server alike. A request that no rule admits gets 401 when it +> carries no valid credentials and 403 otherwise; a missing record answers 404 +> before ownership is checked. ## Generated OIDC routes @@ -158,6 +154,7 @@ automatically for each OIDC binding: |--------|------|-------------| | GET | /api/v1/users/auth/google | Redirect to Google's authorization endpoint | | GET | /api/v1/users/auth/google/callback | Handle the OAuth2 `code` callback, issue a JWT, then 302 to the frontend | +| GET | /api/v1/users/auth/google/logout | Redirect to Google's end-session endpoint | | GET | /api/v1/users/auth/providers | List the external providers bound to this entity | The routes are mounted at `{basePath}[/{version}]/{collection}/auth/...`, where @@ -258,22 +255,27 @@ provider still uses the OIDC config shape (`type: oidc`, `client_id`, ```yaml auth: identifier: email # default identifier field for this entity - via: password # built-in methods only (password, apiKey) - roles: role # enum field that holds the role - bindings: + via: + - provider: password # built-in password login - provider: google # references providers.google identifier: email # field matched against the mapped claims claim_mapping: # map OIDC claims to entity fields email: email # entity_field: oidc_claim displayName: name auto_provision: true # create the user on first login (default true) + roles: role # enum field that holds the role ``` -- `claim_mapping` copies only the listed claims (`entity_field: claim_name`). Claims - are not auto-mapped by matching names. If a mapped claim is absent in the provider - response, that field is simply omitted (not set to null). +- `claim_mapping` copies only the listed claims (`entity_field: claim_name`), + converted to each field's type. Claims are not auto-mapped by matching names. If a + mapped claim is absent in the provider response, that field is simply omitted (not + set to null). Every sign-in brings an existing user's mapped fields up to date. - If the binding `identifier` field is not produced by the claim mapping, the - backend falls back to the `email`, `login`, or `sub` claim to populate it. + backend falls back to the `email`, `login`, or `sub` claim to populate it. Claims + that yield no identifier return 401, as does a sign-in that would duplicate a + unique value. +- A binding may also carry `relations`, which fill a foreign key from claims; see + [Claim mapping](../Reference/auth/claim-mapping.md). - `auto_provision` defaults to `true`: the first login creates a user record. Set it to `false` to require the user to already exist; an unmatched login then returns `403 User not found and auto-provisioning is disabled`. @@ -285,8 +287,8 @@ marks fields as `required`, make sure the mapping covers them or set sensible ## SAML providers -SAML follows the same binding model. Declare a SAML provider and attach it via -`auth.bindings`: +SAML follows the same binding model. Declare a SAML provider and attach it with a +binding object in `auth.via`: ```yaml providers: @@ -300,8 +302,8 @@ entities: type: auth auth: identifier: email - via: password - bindings: + via: + - provider: password - provider: corp identifier: email claim_mapping: @@ -310,11 +312,11 @@ entities: ``` A SAML binding generates `GET /api/v1/users/auth/corp` (login), a -`POST /api/v1/users/auth/corp/callback` assertion-consumer endpoint that returns -`{"token": ..., "user": {...}}` (with `_id`/`_auth` stripped), and a -`GET /api/v1/users/auth/corp/metadata` endpoint serving the service-provider XML. -`auto_provision` defaults to `true` here too; when disabled, an unmatched assertion -returns 403. +`POST /api/v1/users/auth/corp/callback` assertion-consumer endpoint that, like the +OIDC callback, redirects (303) to `{FRONTEND_URL}/auth/callback?code=` for the +SPA to exchange, a `GET /api/v1/users/auth/corp/metadata` endpoint serving the +service-provider XML, and `GET /api/v1/users/auth/corp/logout`. `auto_provision` +defaults to `true` here too; when disabled, an unmatched assertion returns 403. ## Environment variables @@ -334,7 +336,7 @@ is different: the generated backend reads it at **run time** via a `SecretStr` setting (the `_CLIENT_SECRET` pattern), so it must also be present in the environment when you `run`, `build`-and-launch, or deploy -- not only at build time. Keep the literal secret out of the YAML in every case. `FRONTEND_URL` controls where -the OIDC callback redirects after a successful login. +the OIDC and SAML callbacks redirect after a successful login. **Note:** Testing OIDC locally requires a real OAuth app registered with the provider. For Google, create one at @@ -346,9 +348,9 @@ on its default address, that is ## Key points - The top-level `providers:` section defines external OIDC/SAML providers; each is - inert until an auth entity binds it through `auth.bindings`. -- `via:` is for the built-in `password` and `apiKey` methods. External providers are - attached through `auth.bindings`, where `claim_mapping` and `auto_provision` live. + inert until an auth entity binds it in `auth.via`. +- `auth.via` lists `password`, `apiKey`, and binding objects for external providers; + `claim_mapping`, `auto_provision`, and `relations` live in the binding object. - OIDC providers require `discovery_url`; there is no well-known-provider inference. - `auto_provision` defaults to `true`; with it disabled an unmatched login returns 403. - All login methods (password, OIDC, SAML) issue the same app JWT, so access rules diff --git a/docs/v0.2/concepts.md b/docs/v0.2/concepts.md index 7560de57..19d9cd1f 100644 --- a/docs/v0.2/concepts.md +++ b/docs/v0.2/concepts.md @@ -111,20 +111,17 @@ Controllers declare access with the `access:` keyword, either on the whole contr - `public`: open, no credentials required (the default for a minified controller, so lock writes down explicitly). - `"*"`: any valid principal. - `:*`: any member of that identity collection. -- `:`: a principal of that collection holding that exact role. +- `:`: a principal of that collection holding that role, directly or through the role hierarchy. Ownership and conditional access use `when` rules. Every root the `when` references must be declared in the rule's `given` block (binding either an identity or a document/collection); an undeclared root is a build-time error (`WHEN_UNBOUND_SYMBOL`). `principal` and `document` are conventional names, not built-in roots. Document-bound rules are evaluated after the target record is fetched (a missing record returns 404 before any ownership check). `when` operators are the words `equals`, `not equals`, `and`, `or`, `not`, `in`, and `not in`; operands are field references and single-quoted strings. Stored identity fields are read through `.record.`. -> **Enforcement status** +> **Enforcement** > -> Roles (exact match), `:*` scoping, and ownership/ABAC `when` rules +> Roles, role hierarchies, `:*` scoping, and ownership/ABAC `when` rules > are enforced by the generated FastAPI backend, the generated DRF backend, and the -> `stackform run` dev server alike. A request that fails a rule gets 401 (missing or -> invalid credentials), 403 (authenticated but not allowed), or 404 (record not -> found, checked before ownership). Two current limitations: role hierarchy (for -> example `admin` inheriting `moderator`) is accepted in YAML but not yet applied -> during enforcement -- only exact role matches are checked; and conditional or -> ownership rules require a v0.2 spec, because v0.1 specs do not parse `when` rules. +> `stackform run` dev server alike. A request that no rule admits gets 401 when it +> carries no valid credentials and 403 otherwise; a missing record answers 404 +> before ownership is checked. ## Glossary diff --git a/docs/v0.2/troubleshooting.md b/docs/v0.2/troubleshooting.md index 52caa534..83bc5427 100644 --- a/docs/v0.2/troubleshooting.md +++ b/docs/v0.2/troubleshooting.md @@ -46,16 +46,37 @@ Common codes: and tabs (use spaces). - `YAML_ROOT_INVALID` -- the document parsed but the top level is not a mapping. - `SCHEMA_VALIDATION_ERROR` -- valid YAML, but the structure does not match the schema. - Fix the field name or type at the `path` shown. + Fix the field name or type at the `path` shown. This includes an unknown key (see the + [grammar reference](./Reference/grammar.md)) and a `crud` value outside `list`, `get`, + `create`, `update`, `replace`, `delete`. - `ID_FIELD_MISSING` / `ID_FIELD_MULTIPLE` -- an entity must declare exactly one `type: id` field (or a natural `pk:`). Zero or two `id` fields are rejected. -- `CRUD_OPERATION_INVALID` -- a `repositories..crud` list contains a value that is - not one of `list`, `get`, `create`, `update`, `replace`, `delete`. -- `OPERATION_MISSING_QUERY_OR_HANDLER` -- a custom `repositories..operations.` - declares neither a `query:` nor a `handler:`. -- `QUERY_COMMAND_UNKNOWN` / `QUERY_MISSING_COLLECTION` -- a query op uses an unknown - command or targets no collection (the repository has no entity and no - `query.collection`). +- `SCHEMA_VALIDATION_ERROR` "an operation declares exactly one of 'query' or 'handler'" + -- a custom `repositories..operations.` declares neither or both. +- `QUERY_COMMAND_UNKNOWN` / `QUERY_MISSING_FIELD` / `QUERY_MISSING_COLLECTION` / + `QUERY_COLLECTION_NOT_FOUND` -- a query op uses an unknown command, lacks a field its + command needs, targets no collection (the repository has no entity and no + `query.collection`), or names a `query.collection` that is not a declared repository. +- `QUERY_FORM_MISMATCH` / `QUERY_SQL_INVALID` -- a `sql` query on a MongoDB repository or + a `command` query on a PostgreSQL one, or an empty `sql`. +- `LIFECYCLE_FIELD_NOT_FOUND` / `LIFECYCLE_FIELD_NOT_DATETIME` -- `softDelete` or + `ttl.field` names a field the entity lacks or one that is not `type: datetime`. See + [Lifecycle](./Reference/repositories/lifecycle.md). +- `REPOSITORY_EXTENDS_NOT_FOUND` / `REPOSITORY_EXTENDS_NOT_ABSTRACT` / + `REPOSITORY_CIRCULAR_EXTENDS` and the `SERVICE_` equivalents -- `extends` names an + undeclared entry or one without `abstract: true`, or the chain loops back on itself. +- `DESUGAR_ABSTRACT_REPOSITORY_BOUND` / `DESUGAR_ABSTRACT_SERVICE_BOUND` -- a service or + controller binds an abstract repository or service; bind a concrete one that extends it. +- `DESUGAR_SERVICE_NOT_FOUND` -- a controller or route references an undefined service. +- `REPOSITORY_OPERATION_UNDECLARED` -- a route calls an operation that no handler + implements and the repository does not provide. Declare it under the repository's + `operations`, list it in `crud`, or give the service a handler. +- `PAGINATION_UNSUPPORTED` -- `pagination` on a route whose operation is not a repository + `list`. +- `SESSION_ALGORITHM_UNUSED` -- `session.algorithm: RS256` on a spec that never mints a + session token. See [`session:`](./Reference/configuration/session.md). +- `BACKEND_FEATURE_UNSUPPORTED` -- the chosen backend lacks a feature the spec uses, such + as `session.refresh` or `delivery: cookie` on DRF or raw ASGI. - `ACCESS_AMBIGUOUS_ROLE` / `ACCESS_NO_AUTH_ENTITY` / `ACCESS_UNKNOWN_ENTITY` -- an access rule names a role that matches more than one identity entity, references roles when no `type: auth` entity exists, or names an entity that does not exist. @@ -86,9 +107,9 @@ Common codes: `principal.sub equals document.ownerId`, or `document.status equals 'published'`). Bare `true`/`false` literals are not accepted; compare against a quoted string instead. - A stored field on the authenticated record is read via `principal.record.` - (bare `principal` is the subject id from the token). -- Keywords are case-insensitive, but there is no parenthesis grouping; combine clauses - with `and`/`or`. + (`principal.sub` is the subject id from the token). +- Keywords are case-insensitive, and parentheses group clauses + joined with `and`/`or`. ## MongoDB connection refused @@ -220,16 +241,13 @@ You are authenticated but an access rule denied the request -- the wrong role, a `:*` scope you are not in, or an ownership/`when` rule that did not match. Check the access rules on the controller for that operation. -> **Enforcement status** +> **Enforcement** > -> Roles (exact match), `:*` scoping, and ownership/ABAC `when` rules +> Roles, role hierarchies, `:*` scoping, and ownership/ABAC `when` rules > are enforced by the generated FastAPI backend, the generated DRF backend, and the -> `stackform run` dev server alike. A request that fails a rule gets 401 (missing or -> invalid credentials), 403 (authenticated but not allowed), or 404 (record not -> found, checked before ownership). Two current limitations: role hierarchy (for -> example `admin` inheriting `moderator`) is accepted in YAML but not yet applied -> during enforcement -- only exact role matches are checked; and conditional or -> ownership rules require a v0.2 spec, because v0.1 specs do not parse `when` rules. +> `stackform run` dev server alike. A request that no rule admits gets 401 when it +> carries no valid credentials and 403 otherwise; a missing record answers 404 +> before ownership is checked. ## JWT_SECRET warning @@ -257,7 +275,7 @@ block in the spec, and exit 1 with one of: ## SSO and external auth -External providers are attached through `auth.bindings` and need a reachable provider. +External providers are attached through a binding object in `auth.via` and need a reachable provider. Common callback failures (status from `GET /api/v1/{collection}/auth/{provider}/callback`): - `401 {"detail": "Token exchange failed"}` -- the authorization code could not be diff --git a/docs/v0.3/access-reason.md b/docs/v0.3/access-reason.md index 1eb094c1..721f1f7e 100644 --- a/docs/v0.3/access-reason.md +++ b/docs/v0.3/access-reason.md @@ -1,13 +1,13 @@ --- title: Explainable access denials -description: Return a safe, rule-specific reason when an explicit v0.3 deny rule matches. +description: Return a safe, rule-specific reason in a 403 response. --- ## Rule-specific denial details -The optional `reason:` key is a client-visible explanation for an explicit -`then: deny` rule. In a v0.3 spec, when that rule is the first matching access -rule, its reason becomes the `detail` field of the `403 Forbidden` response. +The optional `reason:` key is a client-visible explanation of a `403 Forbidden` +response. When an explicit `then: deny` rule is the first matching access rule, +its reason becomes the `detail` of the response. ```yaml access: @@ -30,17 +30,24 @@ The behavior is the same in `stackform run` and in generated FastAPI, Django REST Framework, and raw ASGI backends. It applies to rules evaluated before a record fetch and to document-scoped rules evaluated after the record is loaded. -## Generic denials +## Denials no rule decides -The response remains `{ "detail": "Forbidden" }` when: +When no rule matches a signed-in caller, the `detail` is the reason of the +first allow rule that carries one, so an allow rule can say what it requires: -- the matching deny rule has no reason or has an empty reason; -- no rule matches and authorization falls through; or -- the spec version is v0.1 or v0.2, where `reason:` remains inert metadata. +```yaml +access: + - given: + principal: identity:user + when: principal.record.role in ['editor', 'admin'] + reason: Only editors may publish. +``` -Reasons on allow rules and non-matching deny rules are never returned. Rules -are evaluated in order, so only the first matching rule can determine the -response. +A viewer is answered `{ "detail": "Only editors may publish." }`. The detail is +`Forbidden` when the matching deny rule has no reason, or when no rule matches +and no allow rule has one. An anonymous caller that no rule admits gets `401`, +which carries no reason. A deny rule that does not match never supplies the +detail. ## Security guidance diff --git a/docs/v0.3/access-role.md b/docs/v0.3/access-role.md deleted file mode 100644 index 5390246b..00000000 --- a/docs/v0.3/access-role.md +++ /dev/null @@ -1,76 +0,0 @@ ---- -title: Access-rule `role:` -description: Require a role on the long access-rule form, so a route can gate on a role and still use given, when and bind. ---- - -# Access-rule `role:` - -In v0.3, a long-form access rule can require the principal to hold a role. The -in-process server and generated FastAPI, DRF, and raw-ASGI backends enforce the -same behavior. A v0.2 rule cannot use `role:`. - -The `":"` shorthand already gates a route on a role, but a -shorthand rule is a bare string with nowhere to put `given:`, `when:` or -`bind:`. A route that must both gate on a role and derive its service inputs -from the principal was therefore inexpressible. `role:` moves the requirement -onto the long rule form, where it composes with the rest of the rule. - -## Requiring a role - -```yaml -access: - - given: - principal: identity:user - role: org-admin - bind: - organizationId: $principal.record.organizationId -``` - -The rule matches when the caller authenticated as `user` *and* holds -`org-admin`, and then binds the organization from the caller's own record. A -caller without the role falls through to the next rule, and to 403 if none -matches. - -`role:` requires an identity binding in `given:` -- the role belongs to the -principal, so a rule with no principal to check is rejected with -`ACCESS_ROLE_WITHOUT_IDENTITY`. The role name must appear in that entity's -declared roles, exactly as for the shorthand form. - -Role hierarchies apply: a principal holding a role that includes the required -one matches. - -## Scoping a collection to admins - -Two rules, one per admitted role, keep the scoping identical while widening who -may read the collection: - -```yaml -routes: - listScoped: - method: GET - path: "" - service: listOrganizationRisks - access: - - given: - principal: identity:user - role: org-admin - bind: - organizationId: $principal.record.organizationId - - given: - principal: identity:user - role: trustiant-admin - bind: - organizationId: $principal.record.organizationId -``` - -Because the organization comes from the principal rather than from a slug, a -member of one organization cannot read another's records even if they hold the -role. - -## Errors - -| Code | Cause | -| --- | --- | -| `ACCESS_ROLE_UNSUPPORTED` | `role:` used in a spec below version 0.3 | -| `ACCESS_ROLE_WITHOUT_IDENTITY` | `role:` on a rule whose `given:` binds no identity | -| `ACCESS_RULE_INVALID` | the role is not declared by the principal's entity | diff --git a/docs/v0.3/controller-bind.md b/docs/v0.3/controller-bind.md index e518ddbf..26214a4f 100644 --- a/docs/v0.3/controller-bind.md +++ b/docs/v0.3/controller-bind.md @@ -7,7 +7,8 @@ description: Supply trusted service inputs from an access rule after it authoriz In v0.3, an access rule can supply trusted service inputs after it authorizes a request. The in-process server and generated FastAPI, DRF, and raw-ASGI -backends enforce the same behavior. A v0.2 rule cannot use `bind:`. +backends enforce the same behavior. `bind:` is v0.3 only; a v0.2 spec using it +is rejected with `SCHEMA_VALIDATION_ERROR`. Rules remain ordered. The first matching rule decides the request: a matching deny rule returns 403, while a matching allow rule supplies only its own @@ -82,7 +83,6 @@ Invalid specs fail at build time: | Code | Raised when | | --- | --- | -| `BIND_UNSUPPORTED` | an access rule uses `bind:` before v0.3 | | `UNDEFINED_SYMBOL` | a referenced root is not a route parameter or an identity/request symbol declared by the same rule | | `BIND_TARGET_UNKNOWN` | a bind key is absent from the service `input:` contract | | `BIND_MISSING_REQUIRED_INPUT` | a required service input is supplied by neither the rule nor a route path parameter | diff --git a/docs/v0.3/cookie-bff.md b/docs/v0.3/cookie-bff.md index 99f6bc31..2454fceb 100644 --- a/docs/v0.3/cookie-bff.md +++ b/docs/v0.3/cookie-bff.md @@ -20,7 +20,7 @@ session: On login the server sets a `Secure; HttpOnly; SameSite=Lax` cookie named `session` carrying the app JWT, and a second, JavaScript-readable -`csrf_token` cookie. The login body no longer contains `token`; it returns the +`csrf_token` cookie. The login body carries no `token`; it returns the safe user record plus the freshly minted `csrf_token`. Principal resolution reads the `session` cookie when no `Authorization` header @@ -44,7 +44,7 @@ it back in the `x-csrf-token` header on every mutating call. When `session.refresh` is also enabled, the refresh token is delivered in a third `HttpOnly` cookie (`refresh_token`). The `/refresh` route reads it from the cookie rather than the request body, rotates it, and re-sets all cookies. -Rotation and reuse detection are unchanged. +Rotation and reuse detection work as with body delivery. The refresh cookie remains CSRF-protected when the short-lived session cookie has expired. A client must therefore retain and submit the current CSRF token diff --git a/docs/v0.3/dpop.md b/docs/v0.3/dpop.md index affc3286..facb7593 100644 --- a/docs/v0.3/dpop.md +++ b/docs/v0.3/dpop.md @@ -64,4 +64,4 @@ new proof containing that nonce. - Existing `401 Token exchange failed` and `401 Failed to fetch user info` responses surface provider rejection after the single nonce retry. - `BACKEND_FEATURE_UNSUPPORTED` - a DRF or raw ASGI build requested DPoP. -- `TRUST_IDP_APP_FIELD_FORBIDDEN` - `dpop` was set on a `trust: idp` provider. +- `SCHEMA_VALIDATION_ERROR` - `dpop` was set on a `trust: idp` provider. diff --git a/docs/v0.3/file-datastores.md b/docs/v0.3/file-datastores.md index 5fde3c55..8c8b9e05 100644 --- a/docs/v0.3/file-datastores.md +++ b/docs/v0.3/file-datastores.md @@ -86,7 +86,7 @@ Each family picks its own default, with the same rules: - Two `default: true` entries in the same family is an error. - Several entries and no default is an error only when something needs the default. -Adding a file datastore never changes which record datastore is the default. +Adding a file datastore never changes which record datastore is the default. The `mongo:` shortcut counts as the record datastore, so it combines with file datastores but not with a `mongodb` or `postgres` entry. ## Binding a repository diff --git a/docs/v0.3/file-endpoints.md b/docs/v0.3/file-endpoints.md index 2eee4cde..8f7e855f 100644 --- a/docs/v0.3/file-endpoints.md +++ b/docs/v0.3/file-endpoints.md @@ -105,7 +105,10 @@ that belongs to another record is `404`. just stored are deleted. - After a successful update, replace or delete, files the record no longer references are deleted from the datastore. -- Deleting a record deletes all of its files. +- Deleting a record deletes all of its files. A repository with `softDelete` + keeps the removed record's files, since the record itself is kept. +- When a repository with `ttl` purges an expired record, the record's files are + deleted with it. ## Errors @@ -130,4 +133,4 @@ downloads. - [File datastores](file-datastores.md) - [File fields](file-fields.md) -- [Access and binding grammar reference](grammar-reference.md) +- [Grammar reference](grammar-reference.md) diff --git a/docs/v0.3/file-fields.md b/docs/v0.3/file-fields.md index 47ed5351..14b92662 100644 --- a/docs/v0.3/file-fields.md +++ b/docs/v0.3/file-fields.md @@ -42,7 +42,7 @@ entities: | `disposition` | `attachment` \| `inline` | `attachment` | How browsers are told to handle the download. | `unique`, `default`, `min`, `max`, `minLength`, `maxLength` and `format` do not -apply to files and are rejected with `FIELD_CONSTRAINT_INVALID`. So are +apply to files and are rejected with `SCHEMA_VALIDATION_ERROR`. So are `maxSize`, `types` and `disposition` on a non-file field. ### Arrays of files @@ -50,7 +50,7 @@ apply to files and are rejected with `FIELD_CONSTRAINT_INVALID`. So are `type: array` with `items: { type: file, ... }` holds several files. The item options (`maxSize`, `types`, `disposition`) apply to each file. `maxItems` on the array caps how many files it holds; `maxItems` on any other field is -rejected. +rejected with `SCHEMA_VALIDATION_ERROR`. ### Where file fields are allowed diff --git a/docs/v0.3/free-slug-fetch.md b/docs/v0.3/free-slug-fetch.md index 6750a640..9e7f249f 100644 --- a/docs/v0.3/free-slug-fetch.md +++ b/docs/v0.3/free-slug-fetch.md @@ -1,18 +1,13 @@ --- title: Freely-named single-record tokens -description: Fetch a built-in single-record op by a route-derived token predicate instead of the hardwired `{id}`. +description: Locate the record of a built-in single-record operation from freely named route tokens. --- -## The `{id}` assumption, lifted +## Naming the record token The built-in single-record operations — `get`, `update`, `replace`, -`delete` — used to hardwire the path token `{id}` and load the record by a -route parameter literally named `id`. Renaming the token silently broke the -fetch: the record never loaded and any ownership / `when:` check evaluated -against nothing. - -Under v0.3 a single-record op derives its fetch from the **route template** -instead. A route may name its token freely: +`delete` — locate their record from the **route template**, so a route +may name its token freely: ```yaml controllers: @@ -100,10 +95,8 @@ Each method returns the affected document, or `None` when the complete predicate does not match. The built-in MongoDB and SQL adapters and all generated repositories implement this contract. -## v0.2 compatibility +## In v0.2 -v0.2 built-in single-record ops always use the `{id}` convention. Runtime and -generated backends continue to load directly by `id`, and a missing runtime -parameter yields the exact legacy message `Missing route parameter: id`. -Freely-named and nested record predicates are enabled only when the spec version -is `0.3`. +v0.2 built-in single-record operations use the `{id}` path parameter and load +the record by `id`; a missing parameter answers `Missing route parameter: id`. +Freely-named and nested record predicates are v0.3 only. diff --git a/docs/v0.3/given-where.md b/docs/v0.3/given-where.md index ec8c8a0b..0adc67ea 100644 --- a/docs/v0.3/given-where.md +++ b/docs/v0.3/given-where.md @@ -164,9 +164,9 @@ the generated MongoDB repository passes `criteria` as the filter. A custom development-server repository must implement the abstract method, and a custom generated repository must retain the collection-specific operation. -## v0.2 is unchanged +## In v0.2 -The `where` binding form is available only under `version: "0.3"`. A -`version: "0.2"` document binding (`` or ` by `) -keeps its implicit primary-key / `{id}` fetch and existing behavior; the word -`where` carries no special meaning in v0.2. +The `where` binding form is v0.3 only. A `version: "0.2"` document binding +(`` or ` by `) fetches the record by its primary +key from the `{id}` path parameter, and the word `where` carries no special +meaning. diff --git a/docs/v0.3/grammar-reference.md b/docs/v0.3/grammar-reference.md index f43d8b87..b95e8fee 100644 --- a/docs/v0.3/grammar-reference.md +++ b/docs/v0.3/grammar-reference.md @@ -1,13 +1,336 @@ --- -title: Access and binding grammar reference -description: Complete v0.3 reference for access values, given bindings, route slugs, where predicates, service inputs, forwarding, and rule bindings. +title: Grammar reference +description: Every key a v0.3 spec accepts, and the access and binding grammar inside them. --- -This page collects the access and binding forms accepted by a v0.3 spec. The -JSON schema validates their outer shape; the parsers and semantic validators -enforce the grammar, declared names, symbol scopes, and wiring rules described -below. The live server and generated FastAPI, Django REST Framework, and raw-ASGI -backends implement the same behavior. +This page lists every key a v0.3 spec accepts, then the access and binding +forms used inside those keys. The JSON schema validates the keys and their +shape; the parsers and semantic validators enforce the grammar, declared names, +symbol scopes, and wiring rules described below. The live server and generated +FastAPI, Django REST Framework, and raw-ASGI backends implement the same +behavior. + +## Spec keys + +Every key a `version: "0.3"` spec accepts. `` stands for a name you +choose. A key outside these tables, or a value of the wrong type, fails schema +validation with `SCHEMA_VALIDATION_ERROR`. Keys v0.3 shares with v0.2 behave as +the v0.2 reference describes. + +### Version + +| Key | Type | Reference | +| --- | --- | --- | +| `version` | `"0.3"` | [Spec version 0.3](spec-version.md) | + +### API + +| Key | Type | Reference | +| --- | --- | --- | +| `api` | object | [api](../v0.2/Reference/configuration/api.md) | +| `api.basePath` | string | [api](../v0.2/Reference/configuration/api.md) | +| `api.version` | string | [api](../v0.2/Reference/configuration/api.md) | +| `api.port` | integer, 1 to 65535 | [api](../v0.2/Reference/configuration/api.md) | +| `api.operations` | object | [api](../v0.2/Reference/configuration/api.md) | +| `api.operations.defaults` | object | [api](../v0.2/Reference/configuration/api.md) | +| `api.operations.defaults.list` | boolean | [api](../v0.2/Reference/configuration/api.md) | +| `api.operations.defaults.get` | boolean | [api](../v0.2/Reference/configuration/api.md) | +| `api.operations.defaults.create` | boolean | [api](../v0.2/Reference/configuration/api.md) | +| `api.operations.defaults.update` | boolean | [api](../v0.2/Reference/configuration/api.md) | +| `api.operations.defaults.replace` | boolean | [api](../v0.2/Reference/configuration/api.md) | +| `api.operations.defaults.delete` | boolean | [api](../v0.2/Reference/configuration/api.md) | +| `api.operations.auth` | object | [api](../v0.2/Reference/configuration/api.md) | +| `api.operations.auth.register` | boolean | [api](../v0.2/Reference/configuration/api.md) | +| `api.operations.auth.login` | boolean | [api](../v0.2/Reference/configuration/api.md) | +| `api.cors` | object | [api](../v0.2/Reference/configuration/api.md) | +| `api.cors.origins` | list of strings | [api](../v0.2/Reference/configuration/api.md) | +| `api.cors.methods` | list of strings | [api](../v0.2/Reference/configuration/api.md) | +| `api.cors.headers` | list of strings | [api](../v0.2/Reference/configuration/api.md) | +| `api.cors.credentials` | boolean | [api](../v0.2/Reference/configuration/api.md) | +| `api.securityHeaders` | boolean | [api](../v0.2/Reference/configuration/api.md) | +| `api.files` | object | [File fields](file-fields.md#limits-and-precedence) | +| `api.files.maxSize` | integer or size string | [File fields](file-fields.md#limits-and-precedence) | +| `api.files.types` | list of MIME types | [File fields](file-fields.md#limits-and-precedence) | +| `api.files.maxFiles` | integer | [File fields](file-fields.md#limits-and-precedence) | + +### Imports + +| Key | Type | Reference | +| --- | --- | --- | +| `imports` | map | [Vendored imports](vendored-imports.md) | +| `imports..path` | string | [Vendored imports](vendored-imports.md) | +| `imports..package` | Python identifier | [Vendored imports](vendored-imports.md) | +| `imports..requires` | list of strings | [Vendored imports](vendored-imports.md#dependency-merging) | + +### MongoDB + +| Key | Type | Reference | +| --- | --- | --- | +| `mongo` | object | [mongo](../v0.2/Reference/configuration/mongo.md) | +| `mongo.uri` | string | [mongo](../v0.2/Reference/configuration/mongo.md) | +| `mongo.database` | string | [mongo](../v0.2/Reference/configuration/mongo.md) | + +### Datastores + +| Key | Type | Reference | +| --- | --- | --- | +| `datastores` | map | [datastores](../v0.2/Reference/configuration/datastores.md) | +| `datastores..type` | `mongodb` \| `mongo` \| `postgres` \| `postgresql` \| `filesystem` \| `s3` | [datastores](../v0.2/Reference/configuration/datastores.md), [File datastores](file-datastores.md) | +| `datastores..default` | boolean | [datastores](../v0.2/Reference/configuration/datastores.md) | +| `datastores..uri` | string | [datastores](../v0.2/Reference/configuration/datastores.md) | +| `datastores..database` | string | [datastores](../v0.2/Reference/configuration/datastores.md) | +| `datastores..dsn` | string | [datastores](../v0.2/Reference/configuration/datastores.md) | +| `datastores..path` | string | [File datastores](file-datastores.md#type-filesystem) | +| `datastores..bucket` | string | [File datastores](file-datastores.md#type-s3) | +| `datastores..endpoint` | string | [File datastores](file-datastores.md#type-s3) | +| `datastores..region` | string | [File datastores](file-datastores.md#type-s3) | +| `datastores..accessKey` | string | [File datastores](file-datastores.md#type-s3) | +| `datastores..secretKey` | string | [File datastores](file-datastores.md#type-s3) | +| `datastores..prefix` | string | [File datastores](file-datastores.md#type-s3) | +| `datastores..files` | object, file datastores only | [File fields](file-fields.md#limits-and-precedence) | +| `datastores..files.maxSize` | integer or size string | [File fields](file-fields.md#limits-and-precedence) | +| `datastores..files.types` | list of MIME types | [File fields](file-fields.md#limits-and-precedence) | + +### Entities + +| Key | Type | Reference | +| --- | --- | --- | +| `entities` | map | [entities](../v0.2/Reference/entities/overview.md) | +| `entities..type` | `base` \| `auth` | [entities](../v0.2/Reference/entities/overview.md) | +| `entities..extends` | string | [inheritance](../v0.2/Reference/entities/inheritance.md) | +| `entities..abstract` | boolean | [inheritance](../v0.2/Reference/entities/inheritance.md) | +| `entities..pk` | string | [primary keys](../v0.2/Reference/entities/primary-keys.md) | +| `entities..fields` | map of [fields](#field-keys) | [field types](../v0.2/Reference/entities/field-types.md) | +| `entities..auth` | object, `type: auth` only | [auth](../v0.2/Reference/auth/overview.md) | +| `entities..auth.identifier` | string | [auth](../v0.2/Reference/auth/overview.md) | +| `entities..auth.roles` | string | [auth](../v0.2/Reference/auth/overview.md) | +| `entities..auth.via` | string, list of strings, or list of objects | [login and SSO](../v0.2/Reference/auth/login-and-sso.md) | +| `entities..auth.via[].provider` | string | [login and SSO](../v0.2/Reference/auth/login-and-sso.md) | +| `entities..auth.via[].identifier` | string | [login and SSO](../v0.2/Reference/auth/login-and-sso.md) | +| `entities..auth.via[].claim_mapping` | map of strings | [claim mapping](../v0.2/Reference/auth/claim-mapping.md) | +| `entities..auth.via[].auto_provision` | boolean | [login and SSO](../v0.2/Reference/auth/login-and-sso.md) | +| `entities..auth.via[].relations` | map | [claim mapping](../v0.2/Reference/auth/claim-mapping.md) | +| `entities..auth.via[].relations..collection` | string | [claim mapping](../v0.2/Reference/auth/claim-mapping.md) | +| `entities..auth.via[].relations..match` | map of strings | [claim mapping](../v0.2/Reference/auth/claim-mapping.md) | +| `entities..roles` | object, `type: auth` only | [roles](../v0.2/Reference/roles/overview.md) | +| `entities..roles.values` | list of strings | [roles](../v0.2/Reference/roles/overview.md) | +| `entities..roles.default` | string | [roles](../v0.2/Reference/roles/overview.md) | +| `entities..roles.hierarchy` | map of role lists | [role hierarchy](../v0.2/Reference/roles/hierarchy.md) | + +### Field keys + +An entity field (`entities..fields.`), a DTO field +(`dto..fields.`), a service input field +(`services..input.`) and a nested field (`items`, or +`properties.`) each take `type` and the keys of that type. `items` takes +no `required`, `readonly` or `default`. + +| Key | Accepted on | Type | Reference | +| --- | --- | --- | --- | +| `type` | every field | `string` \| `number` \| `boolean` \| `datetime` \| `enum` \| `array` \| `object` \| `ref` \| `id` \| `file` | [field types](../v0.2/Reference/entities/field-types.md), [File fields](file-fields.md) | +| `required` | every field except `items` | boolean | [field types](../v0.2/Reference/entities/field-types.md) | +| `readonly` | every field except `items` | boolean | [field types](../v0.2/Reference/entities/field-types.md) | +| `default` | every field except `items`, every type except `file` | a value of the field's type | [field types](../v0.2/Reference/entities/field-types.md) | +| `unique` | top-level entity fields, every type except `file` | boolean | [field types](../v0.2/Reference/entities/field-types.md) | +| `minLength` | `string` | integer | [field types](../v0.2/Reference/entities/field-types.md) | +| `maxLength` | `string` | integer | [field types](../v0.2/Reference/entities/field-types.md) | +| `format` | `string` | `email` \| `e164` \| `uri` \| `uuidv7` \| `uuidv4` | [field types](../v0.2/Reference/entities/field-types.md) | +| `format` | `id` | `uuidv7` \| `uuidv4` | [primary keys](../v0.2/Reference/entities/primary-keys.md) | +| `min` | `number` | number | [field types](../v0.2/Reference/entities/field-types.md) | +| `max` | `number` | number | [field types](../v0.2/Reference/entities/field-types.md) | +| `values` | `enum`, required | list of strings | [field types](../v0.2/Reference/entities/field-types.md) | +| `items` | `array`, required | nested field | [field types](../v0.2/Reference/entities/field-types.md) | +| `maxItems` | `array` of `file` items | integer | [File fields](file-fields.md#arrays-of-files) | +| `properties` | `object` | map of nested fields | [field types](../v0.2/Reference/entities/field-types.md) | +| `additionalProperties` | `object` | boolean | [field types](../v0.2/Reference/entities/field-types.md) | +| `ref` | `ref`, required | entity name | [field types](../v0.2/Reference/entities/field-types.md) | +| `maxSize` | `file` | integer or size string | [File fields](file-fields.md#options) | +| `types` | `file` | list of MIME types | [File fields](file-fields.md#options) | +| `disposition` | `file` | `attachment` \| `inline` | [File fields](file-fields.md#disposition) | + +`type: id` is accepted on a top-level entity field only. A `file` field, or an +array of `file` items, is accepted on a top-level entity field only; see +[where file fields are allowed](file-fields.md#where-file-fields-are-allowed). + +### Repositories + +| Key | Type | Reference | +| --- | --- | --- | +| `repositories` | map | [repositories](../v0.2/Reference/repositories/overview.md) | +| `repositories..entity` | string | [repositories](../v0.2/Reference/repositories/overview.md) | +| `repositories..datastore` | string | [repositories](../v0.2/Reference/repositories/overview.md) | +| `repositories..files` | string | [File datastores](file-datastores.md#binding-a-repository) | +| `repositories..abstract` | boolean | [repositories](../v0.2/Reference/repositories/overview.md) | +| `repositories..extends` | string | [repositories](../v0.2/Reference/repositories/overview.md) | +| `repositories..crud` | list of `list` \| `get` \| `create` \| `update` \| `replace` \| `delete` | [composable CRUD](../v0.2/Reference/repositories/composable-crud.md) | +| `repositories..softDelete` | string | [lifecycle](../v0.2/Reference/repositories/lifecycle.md) | +| `repositories..ttl` | object | [lifecycle](../v0.2/Reference/repositories/lifecycle.md) | +| `repositories..ttl.field` | string | [lifecycle](../v0.2/Reference/repositories/lifecycle.md) | +| `repositories..ttl.seconds` | integer | [lifecycle](../v0.2/Reference/repositories/lifecycle.md) | +| `repositories..operations` | map | [operations](../v0.2/Reference/repositories/operations.md#custom-operations) | +| `repositories..operations..handler` | string | [operations](../v0.2/Reference/repositories/operations.md#custom-operations) | +| `repositories..operations..includeDeleted` | boolean, with `query` only | [lifecycle](../v0.2/Reference/repositories/lifecycle.md#includedeleted) | +| `repositories..operations..query` | object | [operations](../v0.2/Reference/repositories/operations.md#custom-operations) | +| `repositories..operations..query.command` | string | [operations](../v0.2/Reference/repositories/operations.md#custom-operations) | +| `repositories..operations..query.collection` | string | [operations](../v0.2/Reference/repositories/operations.md#custom-operations) | +| `repositories..operations..query.key` | string | [operations](../v0.2/Reference/repositories/operations.md#custom-operations) | +| `repositories..operations..query.sql` | string | [operations](../v0.2/Reference/repositories/operations.md#custom-operations) | +| `repositories..operations..query.params` | map | [operations](../v0.2/Reference/repositories/operations.md#custom-operations) | +| `repositories..operations..query.filter` | any | [operations](../v0.2/Reference/repositories/operations.md#custom-operations) | +| `repositories..operations..query.pipeline` | any | [operations](../v0.2/Reference/repositories/operations.md#custom-operations) | +| `repositories..operations..query.document` | any | [operations](../v0.2/Reference/repositories/operations.md#custom-operations) | +| `repositories..operations..query.documents` | any | [operations](../v0.2/Reference/repositories/operations.md#custom-operations) | +| `repositories..operations..query.update` | any | [operations](../v0.2/Reference/repositories/operations.md#custom-operations) | +| `repositories..operations..query.replacement` | any | [operations](../v0.2/Reference/repositories/operations.md#custom-operations) | +| `repositories..operations..query.requests` | any | [operations](../v0.2/Reference/repositories/operations.md#custom-operations) | + +### Services + +| Key | Type | Reference | +| --- | --- | --- | +| `services` | map | [services](../v0.2/Reference/services/overview.md) | +| `services..repository` | string | [services](../v0.2/Reference/services/overview.md) | +| `services..operation` | string | [services](../v0.2/Reference/services/overview.md) | +| `services..handler` | string | [Service `input:`](service-input.md) | +| `services..abstract` | boolean | [services](../v0.2/Reference/services/overview.md#inheritance-with-abstract-and-extends) | +| `services..extends` | string | [services](../v0.2/Reference/services/overview.md#inheritance-with-abstract-and-extends) | +| `services..operations` | map of booleans or objects | [services](../v0.2/Reference/services/overview.md#per-operation-overrides) | +| `services..operations..handler` | string | [services](../v0.2/Reference/services/overview.md#per-operation-overrides) | +| `services..input` | map of [fields](#field-keys) or type names, or list of DTO names | [Service `input:`](service-input.md) | +| `services..with` | map of `$` references or scalars | [Service `input:`](service-input.md#forwarding-values) | + +### Controllers + +`` stands for `controllers.`, `controllers..operations.` +or `controllers..routes.`, and `` for +`controllers..operations.` or `controllers..routes.`. + +| Key | Type | Reference | +| --- | --- | --- | +| `controllers` | map | [controllers](../v0.2/Reference/controllers/overview.md) | +| `controllers..service` | string | [controllers](../v0.2/Reference/controllers/overview.md) | +| `controllers..base_path` | string | [controllers](../v0.2/Reference/controllers/overview.md) | +| `controllers..operations` | map of booleans or objects | [operations](../v0.2/Reference/controllers/operations.md) | +| `controllers..operations..method` | `GET` \| `POST` \| `PUT` \| `PATCH` \| `DELETE` | [operations](../v0.2/Reference/controllers/operations.md) | +| `controllers..operations..path` | string | [operations](../v0.2/Reference/controllers/operations.md) | +| `controllers..routes` | map | [controllers](../v0.2/Reference/controllers/overview.md) | +| `controllers..routes..service` | string, without controller `service` only | [controllers](../v0.2/Reference/controllers/overview.md) | +| `controllers..routes..method` | `GET` \| `POST` \| `PUT` \| `PATCH` \| `DELETE`, without controller `service` only | [controllers](../v0.2/Reference/controllers/overview.md) | +| `controllers..routes..path` | string, without controller `service` only | [controllers](../v0.2/Reference/controllers/overview.md) | +| `.access` | [access value](#access-values) | [Access values](#access-values) | +| `.access[].given` | map of [bindings](#given-bindings) | [`given:` bindings](#given-bindings) | +| `.access[].when` | string or list of strings | [When expressions](../v0.2/Reference/when-expressions/overview.md) | +| `.access[].then` | `allow` \| `deny` | [Access-rule objects](#access-rule-objects) | +| `.access[].reason` | string | [Explainable access denials](access-reason.md) | +| `.access[].bind` | map of `$` references or scalars | [Access-rule `bind:`](controller-bind.md) | +| `.pagination` | object, list routes only | [Pagination](pagination.md) | +| `.pagination.strategy` | `offset` \| `keyset` | [Pagination](pagination.md#strategies) | +| `.pagination.size` | object | [Pagination](pagination.md) | +| `.pagination.size.default` | integer | [Pagination](pagination.md) | +| `.pagination.size.max` | integer | [Pagination](pagination.md) | +| `.pagination.count` | `none` \| `exact` | [Pagination](pagination.md#envelopes) | +| `.pagination.onOverflow` | `clamp` \| `reject` | [Pagination](pagination.md) | +| `.pagination.envelope` | boolean | [Pagination](pagination.md#envelopes) | +| `.pagination.sort` | list of objects | [Pagination](pagination.md) | +| `.pagination.sort[].field` | string | [Pagination](pagination.md) | +| `.pagination.sort[].dir` | `asc` \| `desc` | [Pagination](pagination.md) | + +### Providers + +| Key | Type | Reference | +| --- | --- | --- | +| `providers` | map | [providers](../v0.2/Reference/providers/overview.md) | +| `providers..type` | `oidc` \| `saml` | [providers](../v0.2/Reference/providers/overview.md) | +| `providers..trust` | `app` \| `idp`, OIDC only | [Dual-trust token dispatch](trust-dispatch.md) | +| `providers..client_id` | string, OIDC only | [OIDC](../v0.2/Reference/providers/oidc.md) | +| `providers..discovery_url` | string, OIDC only | [OIDC](../v0.2/Reference/providers/oidc.md) | +| `providers..client_secret` | string, OIDC `trust: app` only | [OIDC](../v0.2/Reference/providers/oidc.md) | +| `providers..pkce` | boolean, OIDC `trust: app` only | [PKCE](pkce.md) | +| `providers..dpop` | boolean, OIDC `trust: app` only | [DPoP](dpop.md) | +| `providers..issuer` | string, OIDC `trust: idp` only | [IdP token verification](verify-idp.md) | +| `providers..audiences` | list of strings, OIDC `trust: idp` only | [IdP token verification](verify-idp.md) | +| `providers..idp_metadata_url` | string, SAML only | [SAML](../v0.2/Reference/providers/saml.md) | +| `providers..sp_entity_id` | string, SAML only | [SAML](../v0.2/Reference/providers/saml.md) | + +### Session + +| Key | Type | Reference | +| --- | --- | --- | +| `session` | object | [session](../v0.2/Reference/configuration/session.md) | +| `session.algorithm` | `HS256` \| `RS256` | [RS256 session tokens](rs256-signing.md) | +| `session.ttl` | integer | [session](../v0.2/Reference/configuration/session.md) | +| `session.refresh` | boolean | [Refresh-token rotation](refresh-tokens.md) | +| `session.delivery` | `token-url` \| `cookie` | [Cookie (BFF) session delivery](cookie-bff.md) | + +### DTOs + +| Key | Type | Reference | +| --- | --- | --- | +| `dto` | map | [DTOs](../v0.2/Reference/dto/overview.md) | +| `dto..fields` | map of [fields](#field-keys) | [DTOs](../v0.2/Reference/dto/overview.md) | + +### Policies + +| Key | Type | Reference | +| --- | --- | --- | +| `policies` | map | [policies](../v0.2/Reference/policies/overview.md) | +| `policies..given` | map of strings | [policies](../v0.2/Reference/policies/overview.md) | +| `policies..params` | list of strings | [policies](../v0.2/Reference/policies/overview.md) | +| `policies..when` | string or list of strings | [policies](../v0.2/Reference/policies/overview.md) | + +### UI + +| Key | Type | Reference | +| --- | --- | --- | +| `ui` | object | [UI](../v0.2/Reference/ui/overview.md) | +| `ui.frontend` | object | [frontend](../v0.2/Reference/ui/frontend.md) | +| `ui.frontend.pack` | string | [frontend](../v0.2/Reference/ui/frontend.md) | +| `ui.frontend.output` | string | [frontend](../v0.2/Reference/ui/frontend.md) | +| `ui.frontend.overwrite` | `never` \| `always` | [frontend](../v0.2/Reference/ui/frontend.md) | +| `ui.config` | map | [UI](../v0.2/Reference/ui/overview.md) | +| `ui.collections` | map | [collections](../v0.2/Reference/ui/collections.md) | +| `ui.collections..fields` | map | [collections](../v0.2/Reference/ui/collections.md) | +| `ui.collections..fields..from` | string | [collections](../v0.2/Reference/ui/collections.md) | +| `ui.collections..fields..format` | string | [collections](../v0.2/Reference/ui/collections.md) | +| `ui.components` | map | [components](../v0.2/Reference/ui/components.md) | +| `ui.components..props` | map | [components](../v0.2/Reference/ui/components.md) | +| `ui.components..props..required` | boolean | [components](../v0.2/Reference/ui/components.md) | +| `ui.components..render` | object | [components](../v0.2/Reference/ui/components.md) | +| `ui.components..render.component` | string | [components](../v0.2/Reference/ui/components.md) | +| `ui.components..render.props` | map | [components](../v0.2/Reference/ui/components.md) | +| `ui.views` | map | [views](../v0.2/Reference/ui/views.md) | +| `ui.views..path` | string | [views](../v0.2/Reference/ui/views.md) | +| `ui.views..component` | string | [views](../v0.2/Reference/ui/views.md) | +| `ui.views..props` | map | [views](../v0.2/Reference/ui/views.md) | + +### Deploy + +| Key | Type | Reference | +| --- | --- | --- | +| `deploy` | object | [deploy](../v0.2/Reference/configuration/deploy.md) | +| `deploy.name` | string | [deploy](../v0.2/Reference/configuration/deploy.md) | +| `deploy.port` | integer | [deploy](../v0.2/Reference/configuration/deploy.md) | +| `deploy.runtime` | `docker` \| `systemd` | [deploy](../v0.2/Reference/configuration/deploy.md) | +| `deploy.services` | map | [deploy](../v0.2/Reference/configuration/deploy.md) | +| `deploy.services..image` | string | [deploy](../v0.2/Reference/configuration/deploy.md) | +| `deploy.services..build` | string or object | [deploy](../v0.2/Reference/configuration/deploy.md) | +| `deploy.services..build.context` | string | [deploy](../v0.2/Reference/configuration/deploy.md) | +| `deploy.services..build.dockerfile` | string | [deploy](../v0.2/Reference/configuration/deploy.md) | +| `deploy.services..build.args` | map | [deploy](../v0.2/Reference/configuration/deploy.md) | +| `deploy.services..ports` | list of integers or strings | [deploy](../v0.2/Reference/configuration/deploy.md) | +| `deploy.services..environment` | map or list of strings | [deploy](../v0.2/Reference/configuration/deploy.md) | +| `deploy.services..volumes` | list of strings | [deploy](../v0.2/Reference/configuration/deploy.md) | +| `deploy.services..depends_on` | list of strings or map | [deploy](../v0.2/Reference/configuration/deploy.md) | +| `deploy.services..depends_on..condition` | `service_started` \| `service_healthy` \| `service_completed_successfully` | [deploy](../v0.2/Reference/configuration/deploy.md) | +| `deploy.services..command` | string or list of strings | [deploy](../v0.2/Reference/configuration/deploy.md) | +| `deploy.include` | list of strings | [deploy](../v0.2/Reference/configuration/deploy.md) | +| `deploy.mongo` | object | [deploy](../v0.2/Reference/configuration/deploy.md) | +| `deploy.mongo.local` | boolean | [deploy](../v0.2/Reference/configuration/deploy.md) | +| `deploy.docker` | object | [deploy](../v0.2/Reference/configuration/deploy.md) | +| `deploy.docker.image` | string | [deploy](../v0.2/Reference/configuration/deploy.md) | +| `deploy.systemd` | object | [deploy](../v0.2/Reference/configuration/deploy.md) | +| `deploy.systemd.user` | string | [deploy](../v0.2/Reference/configuration/deploy.md) | +| `deploy.systemd.restart` | string | [deploy](../v0.2/Reference/configuration/deploy.md) | ## Access values @@ -29,10 +352,12 @@ Access ::= "public" | `public` | No principal is required. | | `"*"` | Any authenticated principal. Quote it so YAML does not treat `*` as an alias. | | `:*` | Any principal from the named `type: auth` entity. | -| `:` | A principal from that auth entity with the exact declared role. | -| `` | The exact role on the only auth entity; rejected as ambiguous when multiple auth entities exist. | +| `:` | A principal from that auth entity holding the role. | +| `` | The role on the only auth entity; rejected as ambiguous when multiple auth entities exist. | -Role inheritance is not applied: role matching is exact. A missing `access:` is +A principal holds its own roles and every role they inherit through +`entities..roles.hierarchy`, so with `editor: [viewer]` an editor passes +a `viewer` rule. A missing `access:` is treated as public and emits the `PUBLIC_ROUTE` build warning. An explicit `access: public` also emits a warning so accidental exposure is visible during generation. @@ -58,27 +383,55 @@ access: AccessRule ::= { given?: GivenBindings, when?: WhenExpression | [WhenExpression, ...], - role?: String, then?: "allow" | "deny", reason?: String, bind?: SymbolBinding } ``` -At least one of `given`, `when`, `then`, or `role` is required. `then` defaults -to `allow`. `role:` requires the principal to hold that role, and requires an -identity binding in `given:` to hold it. See -[Access-rule `role:`](access-role.md). Rules are evaluated in order; the first -matching rule decides the request. A matching deny returns 403, and no matching -rule also returns 403. Missing or invalid credentials on a non-public route -return 401. - -In v0.3, `reason:` supplies the safe 403 detail only when its explicit deny rule -matches. See [Explainable access denials](access-reason.md). The former -access-rule `filter` key is rejected with `ACCESS_FILTER_REMOVED`; use a -[`where` binding](#where-bindings) to select a record. `writeFields` is also -rejected in v0.3 because no backend enforces it; restrict accepted values through -a service input contract or custom handler instead. +At least one of `given`, `when`, or `then` is required, and any other key is +rejected with `SCHEMA_VALIDATION_ERROR`. `then` defaults to `allow`. Rules are +evaluated in order; the first matching rule decides the request. A matching deny +returns 403. When no rule matches, an anonymous caller gets 401 and a signed-in +one gets 403. A non-public route answers 401 to a request whose token or +password does not authenticate. + +To require a role together with a condition, keep the role in an `enum` field +of the identity record named by `entities..auth.roles` and test it in +`when:`, for example `principal.record.role in ['editor', 'admin']`. + +`reason:` supplies the safe 403 detail; see +[Explainable access denials](access-reason.md). To fix the value of +a field a client must not choose, declare it `readonly: true` and supply it +through [`bind:`](#access-rule-bind). + +### Row filtering on list routes + +On a `list` route, a rule whose `when:` reads the bound collection becomes a row +filter: the datastore query returns only the rows the rule admits. + +```yaml +operations: + list: + access: + - admin + - given: + principal: identity:user + document: sensitive_data + when: principal.sub equals document.owner_id +``` + +An admin lists every row; any other signed-in user lists only their own. + +### Document bindings on custom routes + +On a route that runs a custom repository operation or a handler, a bare +document binding loads the record named by the last path parameter, within the +scope of the other path parameters that name fields of its collection. The rule +decides on that record before the operation runs; the operation's result is not +row-filtered. A route whose path has no parameter cannot bind its record and is +reported as `DOCUMENT_BINDING_INVALID`; bind a record by another parameter with +` by `. ## `given:` bindings @@ -113,8 +466,8 @@ RequestPart ::= "body" | "path" | "query" | "header" | "cookie" A type suffix is valid only for the whole request (where it types the body) or for `body`, `path`, and `query`. Headers and cookies are not typeable. A body binding is rejected on `get`, `list`, and `delete` because those operations do -not carry a body. A typed request is validated before authorization; invalid -input returns 422 and the access rule does not run. +not carry a body. Authentication runs before the body is read, so an anonymous +caller gets 401 whatever the body holds. Every root used by `when:` must be a key in that same rule's `given:` map. Route slugs are not ambient inside `when:`. Refer to @@ -184,8 +537,8 @@ integers, decimals (including `.5` and `3.`), and exponents such as `1e3` or Each rule receives its own fetched bindings, so equal symbol names in different rules do not collide. A predicate miss makes only that rule fail; it never -returns 404. Evaluation continues, and the final result is the normal 403 if no -rule allows the request. This prevents the route from revealing whether the +returns 404. Evaluation continues, and the final result is the normal 401 or +403 if no rule allows the request. This prevents the route from revealing whether the selected record exists. See [The `where` binding form](given-where.md) for the complete parser and runtime contract. @@ -271,10 +624,10 @@ atomically to reads and writes. See | Status | Condition | | --- | --- | | 401 | A non-public route has no valid principal. | -| 403 | An explicit deny matches, no rule allows, or a `where` lookup misses. | +| 403 | A signed-in caller meets an explicit deny, no allowing rule, or a `where` lookup miss. | | 404 | An authorized single-record operation's route locator matches no target. | | 409 | A datastore unique constraint rejects a write. | -| 422 | Request/service input validation fails. | +| 422 | Request/service input validation fails, including a client value for a `readonly` field. | The first matching rule decides access. Document-dependent rules run after the route record is fetched; `where` bindings are isolated per rule. A v0.3 deny @@ -300,8 +653,6 @@ fields hold upload metadata, so they cannot be compared in `when:` or `where`. | `ACCESS_NO_AUTH_ENTITY` | A bare role is used without any auth entity. | | `ACCESS_AMBIGUOUS_ROLE` | A bare role is used with multiple auth entities. | | `ACCESS_RULE_INVALID` | An access list/rule has an invalid shape or unsafe combination. | -| `ACCESS_FILTER_REMOVED` | A v0.3 rule uses the removed `filter` key. | -| `ACCESS_WRITE_FIELDS_REMOVED` | A v0.3 rule uses the unenforced `writeFields` key. | | `WHEN_EXPR_INVALID` | A `when:` expression is empty or has trailing tokens. | | `WHEN_OPERAND_MISSING` | An operator has no following operand. | | `WHEN_OPERAND_INVALID` | An operand is not a field reference or supported literal. | @@ -314,7 +665,7 @@ fields hold upload metadata, so they cannot be compared in `when:` or `where`. | Code | Condition | | --- | --- | -| `DOCUMENT_BINDING_INVALID` | A document binding is not `` or ` by `. | +| `DOCUMENT_BINDING_INVALID` | A document binding is not `` or ` by `, names an unknown collection, or binds the record of a route that has none. | | `REQUEST_BINDING_INVALID` | A request binding has an unknown part or includes a leaf in `given:`. | | `REQUEST_TYPE_NOT_FOUND` | A request type is empty or not a primitive, entity, or DTO. | | `REQUEST_TYPE_ON_LEAF` | A type is attached to a non-typeable request part. | @@ -336,8 +687,6 @@ fields hold upload metadata, so they cannot be compared in `when:` or `where`. | Code | Condition | | --- | --- | -| `SERVICE_INPUT_UNSUPPORTED` | Service `input:` or `with:` is used before v0.3. | -| `BIND_UNSUPPORTED` | Access-rule `bind:` is used before v0.3. | | `SERVICE_INPUT_INVALID` | `input:` is not a non-empty field map or DTO list. | | `SERVICE_INPUT_FIELD_INVALID` | An inline input field type or `required` flag is invalid. | | `SERVICE_INPUT_DTO_NOT_FOUND` | An `input:` list names an unknown DTO. | @@ -350,7 +699,7 @@ fields hold upload metadata, so they cannot be compared in `when:` or `where`. ## Version boundary `where`, service `input:`/`with:`, access-rule `bind:`, unified -`UNDEFINED_SYMBOL`, rule-specific deny reasons, token-derived record locators, -and derived file endpoints are v0.3 behavior. v0.2 retains its existing access -values, identity/document/request bindings, `{id}` single-record convention, and -legacy symbol errors. +`UNDEFINED_SYMBOL`, token-derived record locators, pagination and file +endpoints are v0.3 only. A v0.2 spec binds identities, documents and requests, +fetches single records by `{id}`, and reports `WHEN_UNBOUND_SYMBOL` for an +unbound `when:` operand. diff --git a/docs/v0.3/index.md b/docs/v0.3/index.md index c794a565..61cdd274 100644 --- a/docs/v0.3/index.md +++ b/docs/v0.3/index.md @@ -4,7 +4,7 @@ description: Spec version 0.3 adds multi-file specs, a richer access-rule gramma --- Spec version 0.3 builds on [v0.2](/docs/v0.2). A spec opts in with -`version: 0.3`; v0.2 specs keep working unchanged. +`version: "0.3"`. ## Spec structure @@ -15,13 +15,10 @@ Spec version 0.3 builds on [v0.2](/docs/v0.2). A spec opts in with ## Access rules and binding -- [Access and binding grammar reference](/docs/v0.3/grammar-reference) — the complete rule grammar -- [Access-rule `role:`](/docs/v0.3/access-role) — requiring a role on the long rule form +- [Grammar reference](/docs/v0.3/grammar-reference) — every spec key and the complete rule grammar - [Access-rule `bind:`](/docs/v0.3/controller-bind) — supplying trusted service inputs from a rule - [The `where` binding form](/docs/v0.3/given-where) — deriving repository filters from the principal -- [Explainable access denials](/docs/v0.3/access-reason) — attaching a reason to a deny rule -- [The removed access-rule `filter` key](/docs/v0.3/removed-filter-key) -- [The removed access-rule `writeFields` key](/docs/v0.3/removed-write-fields-key) +- [Explainable access denials](/docs/v0.3/access-reason) — a safe, rule-specific reason in a 403 ## Services and data @@ -31,6 +28,12 @@ Spec version 0.3 builds on [v0.2](/docs/v0.2). A spec opts in with - [Unique violations and `409 Conflict`](/docs/v0.3/conflict-status) — mapping unique constraints to a status - [MongoDB migrations](/docs/v0.3/mongo-migrations) — revision ledger, autogeneration, and baseline stamping +## Files + +- [File fields](/docs/v0.3/file-fields) — the `file` type, its stored metadata, and size and type limits +- [File datastores](/docs/v0.3/file-datastores) — filesystem and S3-compatible storage for file bytes +- [File endpoints](/docs/v0.3/file-endpoints) — multipart uploads and per-field download, replace and delete routes + ## Authentication - [RS256 session tokens](/docs/v0.3/rs256-signing) — asymmetric session signing diff --git a/docs/v0.3/pagination.md b/docs/v0.3/pagination.md index 0b82de98..269ed313 100644 --- a/docs/v0.3/pagination.md +++ b/docs/v0.3/pagination.md @@ -24,7 +24,7 @@ applies whichever are populated: | `skip` | rows skipped before returning (`int >= 0`) | | `sort` | ordered list of `{ field, dir: asc\|desc }` | | `bounds` | ordered comparison keys compiled lexicographically | -| `count` | `none` \| `exact` \| `estimated` | +| `count` | `none` \| `exact` | Application is uniform and backend-agnostic: @@ -34,9 +34,9 @@ rows = find(q).sort(sort).skip(skip).limit(limit) total = count(filter) when count != none ``` -`filter` is the existing scoping predicate (not a primitive); it ANDs into -`bounds`. Omitting every primitive yields the unbounded v0.2 list, byte for -byte. +`filter` is the route's scoping predicate, including the row filter of its +access rules (not a primitive); it ANDs into `bounds`. Omitting every +primitive yields the unbounded list. `bounds` is the one non-trivial primitive. Each bound is an ordered tuple of comparison keys that compiles to a **lexicographic** comparison, so a keyset @@ -62,8 +62,6 @@ parameters map onto primitives as follows: | `limit` | `limit` (defaulted to `size.default`, clamped to `size.max`) | | `offset` | `skip` | | `after` | `bounds` derived from `sort` (strict `>`/`<` per direction, compound) | -| `since`/`until` | `bounds` on the range field (`gte`/`lt`) -- reserved | -| `cursor` | decoded to `bounds` -- reserved | ### Controller (explicit strategy -- the only strategy site) @@ -78,7 +76,7 @@ controllers: size: { default: 20, max: 100 } count: none # none | exact (default none) onOverflow: clamp # clamp | reject (default clamp) - envelope: true # default false + envelope: true # default false; required by keyset and count: exact ``` ## Strategies @@ -110,15 +108,12 @@ pagination: envelope: true ``` -### cursor / range (reserved) - -`cursor`, `range`, and `count: estimated` are reserved vocabulary with no -runtime. A spec that declares one fails with `PAGINATION_UNSUPPORTED`. ## Envelopes -With `envelope: false` (the default) the response is a bare array -- the v0.2 -shape. With `envelope: true`: +With `envelope: false` (the default) the response is a bare array of offset +rows. Keyset pagination and `count: exact` carry their `next` token and `total` +in the envelope, so they require `envelope: true`. With `envelope: true`: | strategy | envelope | |----------|----------| @@ -133,12 +128,9 @@ to pass as `after` for the following page (`null` on the last page), and | code | raised when | |------|-------------| -| `PAGINATION_INVALID_STRATEGY` | `strategy` is not a recognized value | -| `PAGINATION_UNSUPPORTED` | a reserved strategy or count mode has no runtime | -| `PAGINATION_INVALID_SIZE` | `size.default`/`size.max` missing, non-integer, or `default > max` | -| `PAGINATION_INVALID_COUNT` | `count` is not a recognized value | -| `PAGINATION_INVALID_OVERFLOW` | `onOverflow` is not `clamp`/`reject` | -| `PAGINATION_INVALID_SORT` | a sort entry is malformed | +| `SCHEMA_VALIDATION_ERROR` | an unknown key, a `strategy` other than `offset`/`keyset`, a `count` other than `none`/`exact`, an `onOverflow` other than `clamp`/`reject`, a missing or non-integer `size.default`/`size.max`, keyset or `count: exact` without `envelope: true`, or a sort entry without `field` or with a `dir` other than `asc`/`desc` | +| `PAGINATION_UNSUPPORTED` | `pagination` is set on a route whose operation is not a repository list | +| `PAGINATION_INVALID_SIZE` | `size.default` exceeds `size.max` | | `PAGINATION_SORT_FIELD_UNKNOWN` | a sort field is not a field of the entity | | `PAGINATION_KEYSET_SORT_REQUIRED` | keyset declared without a `sort` | | `PAGINATION_KEYSET_TIEBREAKER_NOT_UNIQUE` | the last keyset sort field is not unique/indexed | diff --git a/docs/v0.3/removed-filter-key.md b/docs/v0.3/removed-filter-key.md deleted file mode 100644 index 80f498de..00000000 --- a/docs/v0.3/removed-filter-key.md +++ /dev/null @@ -1,42 +0,0 @@ ---- -title: The removed access-rule `filter` key -description: The dead `filter` key is gone from the v0.3 access rule; using it aborts with a coded error. ---- - -## The `filter` key is removed - -The access-rule `filter` key was carried in the IR but never enforced by the -runtime or generated backends. Leaving it available could make an apparently -restricted rule authorize more records than intended. In v0.3 it is removed -from the grammar, and a rule that still declares it aborts generation with -`ACCESS_FILTER_REMOVED`: - -```yaml -access: - - given: - principal: identity:user - filter: "ownerId = principal.sub" # ACCESS_FILTER_REMOVED -``` - -The formerly inert `writeFields` key is rejected for the same fail-safe reason; -see [The removed access-rule `writeFields` key](removed-write-fields-key.md). - -## Scope reads through `where` instead - -Restricting which records a principal may reach is the job of the `where` -[binding form](./given-where.md): bind a `given:` symbol to the one record the -predicate selects before the rule runs. A lookup miss makes that rule fail; if -no other rule allows the request, authorization returns 403 without revealing -whether the record exists. - -```yaml -access: - - given: - principal: identity:user - owned: posts where ownerId = $principal.sub -``` - -## v0.2 is unchanged - -Under `version: "0.2"` the `filter` key is still parsed and preserved on the -rule exactly as before; the removal applies only to `version: "0.3"`. diff --git a/docs/v0.3/removed-write-fields-key.md b/docs/v0.3/removed-write-fields-key.md deleted file mode 100644 index 545ca989..00000000 --- a/docs/v0.3/removed-write-fields-key.md +++ /dev/null @@ -1,38 +0,0 @@ ---- -title: The removed access-rule `writeFields` key -description: v0.3 rejects the formerly inert writeFields key instead of presenting an unenforced authorization boundary. ---- - -## `writeFields` is not an authorization control - -The legacy access-rule `writeFields` key was parsed into the intermediate model -but never enforced by the runtime server or generated backends. A spec could -therefore appear to limit which fields a principal may write while accepting the -entire request payload. - -Under `version: "0.3"`, using the key aborts generation with -`ACCESS_WRITE_FIELDS_REMOVED`: - -```yaml -access: - - given: - principal: identity:user - writeFields: [title, content] # ACCESS_WRITE_FIELDS_REMOVED -``` - -Rejecting the declaration is fail-safe: a security-looking rule cannot silently -grant broader write access than its author intended. - -## Restricting write input - -Use a v0.3 service [`input:` contract](service-input.md) to declare and validate -the values an operation accepts. Use access-rule [`bind:`](controller-bind.md) -for trusted values derived from the authorized principal or request. When the -allowed field set itself depends on a role or policy, enforce that policy in a -custom handler rather than declaring `writeFields`. - -## v0.2 compatibility - -v0.2 continues to parse and preserve `writeFields` for compatibility, but it -does not enforce it. Do not rely on the key as an authorization boundary in a -v0.2 application. diff --git a/docs/v0.3/service-input.md b/docs/v0.3/service-input.md index eeb227b2..a5494e31 100644 --- a/docs/v0.3/service-input.md +++ b/docs/v0.3/service-input.md @@ -8,8 +8,8 @@ description: Declare the request values a service requires and map them to its r In v0.3, a service declares the request values it requires with `input:` and maps those validated values to its repository operation or custom handler with `with:`. Both the in-process server and generated FastAPI, DRF, and raw-ASGI -backends enforce the same contract. A v0.2 spec using either key is rejected -with `SERVICE_INPUT_UNSUPPORTED`. +backends enforce the same contract. Both keys are v0.3 only; a v0.2 spec using +either is rejected with `SCHEMA_VALIDATION_ERROR`. ## Declaring inputs @@ -31,15 +31,27 @@ services: input: [PublishRequest] ``` -The supported field types are `string`, `number`, `boolean`, `datetime`, -`enum`, `array`, `object`, and `ref`. The normal `min`, `max`, `minLength`, -`maxLength`, and `values` constraints apply. Fields are required unless their +An input field is `string`, `number`, `boolean`, `datetime`, `enum`, `array`, +`object` or `ref`, and takes the keys an entity field of that type takes (see +[Field types](../v0.2/Reference/entities/field-types.md)). An `object` input +with `properties` checks every nested key and rejects keys it does not declare +unless `additionalProperties: true`. A `ref` input names a declared entity or DTO; any other +target is rejected with `DTO_NOT_FOUND`. A `file` input is rejected with +`FILE_FIELD_CONTEXT_INVALID`; files travel through +[file endpoints](file-endpoints.md). Fields are required unless their configuration sets `required: false`. Values come from JSON body fields and route path parameters. A path parameter wins when the body contains the same name, so a body cannot spoof a route -identity. Missing or invalid inputs return 422 before the repository operation -or handler runs. An optional input may be absent or `null`. +identity, and a matching [access-rule `bind:`](controller-bind.md) wins over +both. A path segment is read as text; a `number` or `boolean` input reads the +number or `true`/`false` its segment spells, so `GET /sized/3` gives a +`size: number` input the value `3`. Missing or invalid inputs return 422 before +the repository operation or handler runs. An optional input may be absent or +`null`. + +A service runs either a repository `operation` or a `handler`, never both; a +service declaring both is rejected with `SCHEMA_VALIDATION_ERROR`. ## Forwarding values @@ -84,18 +96,30 @@ A forwarding key that the repository query does not reference is rejected with `SERVICE_WITH_TARGET_UNKNOWN`. This catches misspelled targets rather than silently discarding a value. -For custom handlers, resolved values are exposed without changing the handler -signature: +A custom handler receives one request object, the same under `stackform run` +and every generated backend, and returns the JSON response body: + +```python +def publish(request): + return {"id": request.inputs["postId"], "by": request.principal["sub"]} +``` + +| Attribute | Value | +| --- | --- | +| `inputs` | The `with:` values, keyed by their `with:` names | +| `params` | Route path parameters | +| `query` | Query parameters; a repeated key keeps its last value | +| `headers`, `cookies` | Request headers and cookies | +| `body` | The JSON body, or `{}` when the body is empty or not JSON | +| `principal` | The signed-in principal, or `None`; its `record` leaves out the stored id and password | -- `stackform run`: `context.service_inputs` -- generated FastAPI and raw-ASGI: `request.state.service_inputs` -- generated DRF: `request.service_inputs` +A handler may be a plain or an `async` function. Raising `ValueError` answers +422. ## Error codes | Code | Raised when | | --- | --- | -| `SERVICE_INPUT_UNSUPPORTED` | `input:` or `with:` is used before v0.3 | | `SERVICE_INPUT_INVALID` | `input:` is neither a non-empty field map nor a non-empty DTO list | | `SERVICE_INPUT_FIELD_INVALID` | an input field type or `required` flag is invalid | | `SERVICE_INPUT_DTO_NOT_FOUND` | an input lists a DTO that does not exist | diff --git a/docs/v0.3/spec-version.md b/docs/v0.3/spec-version.md index 192c445e..ad45c2af 100644 --- a/docs/v0.3/spec-version.md +++ b/docs/v0.3/spec-version.md @@ -1,6 +1,6 @@ --- title: Spec version 0.3 -description: Opt in to the v0.3 spec, a superset of v0.2, and the reserved-vs-active where operators. +description: Opt in to the v0.3 spec, what it adds to v0.2, and the operators a where binding accepts. --- ## Opting in @@ -11,60 +11,38 @@ Select the spec version at the top of your file: version: "0.3" ``` -A `version: "0.3"` spec runs the same shared validation and desugaring pipeline as -v0.2 and is transformed by `V03Transformer`, which subclasses the v0.2 transformer. -A `version: "0.2"` spec is unaffected and behaves exactly as before. An unknown -version (for example `"9.9"`) is rejected with the `UNSUPPORTED_VERSION` error. +A `version: "0.3"` spec runs the same validation and desugaring pipeline as +v0.2. An unknown version (for example `"9.9"`) is rejected with the +`UNSUPPORTED_VERSION` error. -## Compatibility with v0.2 +## Differences from v0.2 -Most v0.2 specs remain valid under v0.3. The exceptions are the dead access-rule -`filter` and `writeFields` keys: neither was enforced, and both are rejected in -v0.3 to prevent a false sense of authorization. The v0.3 schema lives at -`parsers/schemas/yaml-spec-v0.3.schema.json` and derives from v0.2 with these -changes: +A v0.2 spec is a valid v0.3 spec once its `imports` entries use the object form +described below. The v0.3 schema lives at +`parsers/schemas/yaml-spec-v0.3.schema.json` and extends v0.2 with: - **[`where` binding form](./given-where.md)** — access-rule `given:` values may bind a record by predicate over fields, `$`-sigil symbols, and literals (for example `orders where ownerId = $userId and orgId = $orgId`). - **[service `input:` / `with:`](./service-input.md)** — a service declares, validates, and forwards request values to a repository operation or handler. -- **[controller / access-rule `bind:`](./controller-bind.md)** — the matched +- **[access-rule `bind:`](./controller-bind.md)** — the matched authorization rule supplies trusted values for a service's declared inputs. -- **[access-rule `reason:`](./access-reason.md)** — an explicit matching deny - may return a safe, rule-specific explanation in the 403 response. -- **[vendored `imports:`](./vendored-imports.md)** — an `imports` entry may - be an object that copies a custom-handler package into the generated target and +- **[vendored `imports:`](./vendored-imports.md)** — each `imports` entry is + an object that copies a custom-handler package into the generated target and merges its dependencies into the manifest. +- **[pagination](./pagination.md)** — `pagination:` on a list route or + operation override. +- **[file fields](./file-fields.md), [file datastores](./file-datastores.md) and + [file endpoints](./file-endpoints.md)** — `type: file` fields, `api.files`, + `repositories..files` and the `filesystem`/`s3` datastores. +- **[PKCE](./pkce.md) and [DPoP](./dpop.md)** — `pkce` and `dpop` on an OIDC + provider. - **`$`-sigil slugs** — symbolic references such as `$userId`, resolved at build or call time. -- **[removed access-rule `filter`](./removed-filter-key.md)** — v0.3 rejects - this unenforced legacy key with `ACCESS_FILTER_REMOVED`. -- **[removed access-rule `writeFields`](./removed-write-fields-key.md)** — - v0.3 rejects this unenforced legacy rule key with - `ACCESS_WRITE_FIELDS_REMOVED`. -Service `input:` / `with:`, controller `bind:`, and explainable denial reasons -are implemented in the runtime server and generated backends. A construct is -not listed as implemented until its parser, runtime behavior, generated output, -tests, and reference docs are present. +## `where` operators -## `where` operators: active vs reserved - -The `where` operator set is reserved so that specs written against future v0.3 -releases stay within the schema's character set today. Only a subset is active: - -| Operator | Status | -| --- | --- | -| `=` | Active | -| `and` | Active | -| `!=` | Reserved (not yet implemented) | -| `<` | Reserved (not yet implemented) | -| `<=` | Reserved (not yet implemented) | -| `>` | Reserved (not yet implemented) | -| `>=` | Reserved (not yet implemented) | -| `in` | Reserved (not yet implemented) | - -Reserved operators pass the schema but are rejected by the where parser with -`WHERE_OPERATOR_UNSUPPORTED`, so a spec using one does not build. Use only `=` -and `and`. +A `where` binding compares fields with `=` and joins comparisons with `and`. +The operators `!=`, `<`, `<=`, `>`, `>=` and `in` are rejected with +`WHERE_OPERATOR_UNSUPPORTED`. diff --git a/docs/v0.3/symbol-resolution.md b/docs/v0.3/symbol-resolution.md index fe9566ba..98416dc9 100644 --- a/docs/v0.3/symbol-resolution.md +++ b/docs/v0.3/symbol-resolution.md @@ -80,8 +80,8 @@ Service forwarding keys are also bound roots. A repository query may reference `with:`. Validation is per route, so one correctly bound route cannot hide a missing binding on another route to the same operation. -## v0.2 is unchanged +## In v0.2 -This model applies only to `version: "0.3"`. A `version: "0.2"` spec keeps its -existing behavior: `when:` operands report the legacy `WHEN_UNBOUND_SYMBOL` -error, and repository filters keep their ambient `principal` / request roots. +This model applies to `version: "0.3"`. In a `version: "0.2"` spec an unbound +`when:` operand reports `WHEN_UNBOUND_SYMBOL`, and repository filters read the +ambient `principal` and request roots. diff --git a/docs/v0.3/trust-dispatch.md b/docs/v0.3/trust-dispatch.md index 70dd46d9..89ca5629 100644 --- a/docs/v0.3/trust-dispatch.md +++ b/docs/v0.3/trust-dispatch.md @@ -26,13 +26,14 @@ validated by another. ## Provider requirements -`trust: idp` is only valid on `oidc` and `saml` providers. JWT bearer dispatch -applies to OIDC providers; SAML providers use their browser-based flow and are -not treated as JWT issuer verifiers. On such a provider -the app mints no token of its own, so app-signing fields (`algorithm`, -`secret`, `ttl`, `refresh`, `delivery`) are rejected. The issuer is derived from -the discovery document, and the accepted audience defaults to the provider's -`client_id` unless `audiences` is set explicitly. +`trust` is an `oidc` provider key; a `saml` provider has no `trust` and is never +a JWT issuer verifier. Under `trust: idp` the provider takes `issuer` and +`audiences` and rejects `client_secret`, `pkce` and `dpop`; under `trust: app` +it rejects `issuer` and `audiences`. Each violation is a +`SCHEMA_VALIDATION_ERROR`. The issuer is derived from the discovery document +unless `issuer` is set. The accepted audience is `audiences`, or the provider's +`client_id` when `audiences` is omitted, in which case generation reports the +warning `TRUST_IDP_AUDIENCE_DEFAULTED`. ## Signing the app's own tokens diff --git a/docs/v0.3/vendored-imports.md b/docs/v0.3/vendored-imports.md index 7364b206..02bbbbe2 100644 --- a/docs/v0.3/vendored-imports.md +++ b/docs/v0.3/vendored-imports.md @@ -7,7 +7,7 @@ Under `version: "0.3"` an `imports` entry may be an **object** that vendors a custom handler package into the generated backend. The source directory is copied whole into `backend//`, the emitted code imports the vendored package name, and the declared dependencies are merged into the target manifest, -so the generated target no longer needs the author's package to be installed. +so the generated target does not need the author's package to be installed. ```yaml version: "0.3" @@ -26,10 +26,12 @@ imports: identifier and must not collide with a module the backend generator emits. - `requires` — optional dependency specifiers merged into `requirements.txt`. -Call sites are unchanged: a handler still reads `handler: handlers.publish`. The +A handler names the import alias, as in `handler: handlers.publish`. The alias (`handlers`) resolves to the vendored `package`, so the generated backend -emits `import app_handlers` / `await app_handlers.publish(**kwargs)` — never the -author-side dotted path. +emits `import app_handlers` and calls `app_handlers.publish` — never the +author-side dotted path. A handler may also name a module inside the package, as +in `handlers.posts.publish`; the last name must be defined or imported at the top +level of that module. ```yaml repositories: @@ -84,19 +86,19 @@ one output directory neither shadows generated code nor keeps deleted files. ## Version gate -- **v0.2** is untouched: an `imports` entry is a bare dotted string that is - imported (not vendored), and repository-operation handlers stay verbatim. -- **v0.3 without an `imports` block** keeps the same handler semantics: a dotted - handler is emitted verbatim and imported from the target environment. -- **v0.3 with an `imports` block** requires the object form and opts the whole - spec into vendoring: every handler must name a vendored package, and a - bare-string entry is rejected. There is no back-compatibility shim. +- **v0.2**: an `imports` entry is a bare dotted string that is imported (not + vendored), and repository-operation handlers stay verbatim. +- **v0.3 without an `imports` block**: a dotted handler is emitted verbatim and + imported from the target environment. +- **v0.3 with an `imports` block**: every entry is the object form and the whole + spec is vendored, so every handler must name a vendored package. A + bare-string entry is rejected with `SCHEMA_VALIDATION_ERROR`. ## Build errors | Code | Cause | | --- | --- | -| `IMPORT_BARE_STRING` | A v0.3 `imports` entry uses the bare dotted-string form instead of the vendored object. | +| `SCHEMA_VALIDATION_ERROR` | An `imports` entry is not an object with `path` and `package`, or carries another key. | | `IMPORT_PACKAGE_COLLISION` | Two entries vendor the same `package` name. | | `IMPORT_SOURCE_OUTSIDE_SPEC` | An entry's `path` resolves outside the directory holding the spec. | | `IMPORT_SOURCE_MISSING` | An entry's `path` is not an existing directory. | @@ -104,5 +106,6 @@ one output directory neither shadows generated code nor keeps deleted files. | `IMPORT_SOURCE_ESCAPING_SYMLINK` | The vendored tree contains a symlink pointing outside it. | | `IMPORT_PACKAGE_SHADOWS_TARGET` | A vendored `package` name is already occupied by a generated module. | | `IMPORT_UNDECLARED_PACKAGE` | A handler references a package that no `imports` entry vendors. | +| `IMPORT_HANDLER_NOT_FOUND` | A handler names a module or function its vendored package does not define. | | `IMPORT_REQUIRES_INVALID` | A `requires` entry is not a valid dependency specifier. | | `IMPORT_REQUIRES_CONFLICT` | Requirements for one library cannot be merged into one specifier. | diff --git a/docs/v0.3/verify-idp.md b/docs/v0.3/verify-idp.md index 96e53c21..91cfa371 100644 --- a/docs/v0.3/verify-idp.md +++ b/docs/v0.3/verify-idp.md @@ -38,12 +38,43 @@ For a bearer token whose `iss` matches the provider's issuer: - **Issuer** (`iss`) against the provider's configured issuer, or the issuer read from the discovery document when the spec omits it. - **Audience** (`aud`) against `audiences` when set, otherwise against - `client_id`. + `client_id`. See [Keycloak: putting the API in `aud`](#keycloak-putting-the-api-in-aud). Verification yields either a full claim set or nothing; there is no partial acceptance, and a token rejected here is never retried as an app token or an api key. +## Keycloak: putting the API in `aud` + +A Keycloak access token names its audience only through protocol mappers, so +by default its `aud` does not carry the API's client and verification fails +with `Invalid IdP token`. Add an **Audience** mapper so every access token the +clients receive names the API: + +1. In the realm, open **Client scopes** and create a scope, for example + `api-audience`. +2. In its **Mappers** tab choose **Configure a new mapper**, then **Audience**. +3. Set **Included Client Audience** to the API's client (the provider's + `client_id`), or **Included Custom Audience** to a value listed in + `audiences`, and turn **Add to access token** on. +4. In each client that calls the API, open **Client scopes** and add + `api-audience` as a **Default** scope. + +Every access token those clients obtain then carries the API in `aud`: + +```yaml +providers: + keycloak: + type: oidc + trust: idp + client_id: orders-api + discovery_url: https://sso.example.com/realms/main/.well-known/openid-configuration + audiences: [orders-api] +``` + +Linking the scope as **Optional** instead adds the audience only when the +client requests `scope=api-audience`. + ## Failure responses Every rejection on this path answers `401`. The distinct messages a client sees: diff --git a/examples/comparison/auth-api-key-proposed.yaml b/examples/comparison/auth-api-key-proposed.yaml index 90dce635..64a043b3 100644 --- a/examples/comparison/auth-api-key-proposed.yaml +++ b/examples/comparison/auth-api-key-proposed.yaml @@ -2,14 +2,15 @@ version: "0.2" api: operations: - defaults: [list, get, create, update, delete] + defaults: { list: true, get: true, create: true, update: true, delete: true } entities: client: type: auth - via: - - provider: apiKey - identifier: clientId + auth: + via: + - provider: apiKey + identifier: clientId fields: id: type: id @@ -35,18 +36,18 @@ repositories: # Services: only expose the operations we want. # replace is available at the repo level but intentionally not exposed. services: - list-clients: { repository: clients, operation: list } - get-client: { repository: clients, operation: get } - create-client: { repository: clients, operation: create } - update-client: { repository: clients, operation: update } - delete-client: { repository: clients, operation: delete } + list_clients: { repository: clients, operation: list } + get_client: { repository: clients, operation: get } + create_client: { repository: clients, operation: create } + update_client: { repository: clients, operation: update } + delete_client: { repository: clients, operation: delete } controllers: clients: base_path: /clients routes: - list: { service: list-clients, method: GET, path: /, access: authenticated } - get: { service: get-client, method: GET, path: /{id}, access: authenticated } - create: { service: create-client, method: POST, path: /, access: public } - update: { service: update-client, method: PATCH, path: /{id}, access: authenticated } - delete: { service: delete-client, method: DELETE, path: /{id}, access: authenticated } + list: { service: list_clients, method: GET, path: /, access: "*" } + get: { service: get_client, method: GET, path: "/{id}", access: "*" } + create: { service: create_client, method: POST, path: /, access: public } + update: { service: update_client, method: PATCH, path: "/{id}", access: "*" } + delete: { service: delete_client, method: DELETE, path: "/{id}", access: "*" } diff --git a/examples/comparison/auth-users-current.yaml b/examples/comparison/auth-users-current.yaml index 32b6e864..5970a915 100644 --- a/examples/comparison/auth-users-current.yaml +++ b/examples/comparison/auth-users-current.yaml @@ -17,8 +17,8 @@ collections: update: authenticated replace: authenticated delete: - - principal: identity:users - when: "principal.sub == document.id" + - principal: "users:*" + when: "principal.sub equals document.id" fields: id: type: id @@ -42,10 +42,10 @@ collections: get: public update: authenticated delete: - - principal: identity:users - when: "principal.sub == document.authorId" - - principal: identity:users - when: "principal.record.role == 'moderator'" + - principal: "users:*" + when: "principal.sub equals document.authorId" + - principal: "users:*" + when: "principal.record.role equals 'moderator'" fields: id: type: id @@ -57,10 +57,4 @@ collections: authorId: type: ref ref: users - operations: - my-posts: - base: list - path: /posts/mine - access: authenticated - filter: - authorId: $principal + # v0.1 has no custom operations, so a "my posts" listing cannot be declared. diff --git a/examples/comparison/auth-users-proposed.yaml b/examples/comparison/auth-users-proposed.yaml index b5154143..6c26a7c0 100644 --- a/examples/comparison/auth-users-proposed.yaml +++ b/examples/comparison/auth-users-proposed.yaml @@ -4,14 +4,15 @@ api: basePath: /api version: v1 operations: - defaults: [list, get, create, update, replace, delete] + defaults: { list: true, get: true, create: true, update: true, replace: true, delete: true } entities: user: type: auth - via: - - provider: password - identifier: email + auth: + via: + - provider: password + identifier: email fields: id: type: id @@ -42,7 +43,7 @@ entities: ref: user # CRUD is implicit. Custom operations added explicitly. -# posts.by-author is available internally but NOT exposed via any controller. +# posts.by_author is exposed through the my_posts service. repositories: users: entity: user @@ -50,30 +51,29 @@ repositories: posts: entity: post operations: - by-author: - base: list - filter: { authorId: $principal } + by_author: + query: + command: find + filter: { authorId: "{{authorId}}" } # Services: business operations exposed to controllers. # Simple CRUD services delegate to a single repo operation. -# login is a custom service with its own step pipeline. services: register: { repository: users, operation: create } login: repository: users - steps: [validate, authenticate, issue-token] - list-users: { repository: users, operation: list } - get-user: { repository: users, operation: get } - update-user: { repository: users, operation: update } - delete-user: { repository: users, operation: delete } + list_users: { repository: users, operation: list } + get_user: { repository: users, operation: get } + update_user: { repository: users, operation: update } + delete_user: { repository: users, operation: delete } - list-posts: { repository: posts, operation: list } - get-post: { repository: posts, operation: get } - create-post: { repository: posts, operation: create } - update-post: { repository: posts, operation: update } - delete-post: { repository: posts, operation: delete } - my-posts: { repository: posts, operation: by-author } + list_posts: { repository: posts, operation: list } + get_post: { repository: posts, operation: get } + create_post: { repository: posts, operation: create } + update_post: { repository: posts, operation: update } + delete_post: { repository: posts, operation: delete } + my_posts: { repository: posts, operation: by_author } controllers: auth: @@ -85,36 +85,36 @@ controllers: users: base_path: /api/v1/users routes: - list: { service: list-users, method: GET, path: /, access: authenticated } - get: { service: get-user, method: GET, path: /{id}, access: authenticated } - update: { service: update-user, method: PATCH, path: /{id}, access: authenticated } + list: { service: list_users, method: GET, path: /, access: "*" } + get: { service: get_user, method: GET, path: "/{id}", access: "*" } + update: { service: update_user, method: PATCH, path: "/{id}", access: "*" } delete: - service: delete-user + service: delete_user method: DELETE path: /{id} access: - given: - principal: identity:users + principal: identity:user document: users when: principal.sub equals document.id posts: base_path: /api/v1/posts routes: - list: { service: list-posts, method: GET, path: /, access: public } - get: { service: get-post, method: GET, path: /{id}, access: public } - create: { service: create-post, method: POST, path: /, access: public } - update: { service: update-post, method: PATCH, path: /{id}, access: authenticated } + list: { service: list_posts, method: GET, path: /, access: public } + get: { service: get_post, method: GET, path: "/{id}", access: public } + create: { service: create_post, method: POST, path: /, access: public } + update: { service: update_post, method: PATCH, path: "/{id}", access: "*" } delete: - service: delete-post + service: delete_post method: DELETE path: /{id} access: - given: - principal: identity:users + principal: identity:user document: posts when: principal.sub equals document.authorId - given: - principal: identity:users + principal: identity:user when: principal.record.role equals 'moderator' - my-posts: { service: my-posts, method: GET, path: /mine, access: authenticated } + by-author: { service: my_posts, method: GET, path: "/by-author/{authorId}", access: "*" } diff --git a/examples/comparison/auth-users/post_actions.py b/examples/comparison/auth-users/post_actions.py new file mode 100644 index 00000000..ddcbdb50 --- /dev/null +++ b/examples/comparison/auth-users/post_actions.py @@ -0,0 +1,14 @@ +from yaml_api_generator.server.common.request_context import RequestContext + + +async def publish(ctx: RequestContext) -> RequestContext: + """Set a post's published flag to true.""" + post_id = ctx.url_params.get("id") + if post_id is None: + raise ValueError("Missing post id") + + result = await ctx.repository.update( + "posts", post_id, {"published": True} + ) + ctx.response = result + return ctx diff --git a/examples/comparison/auth-users/v0.1.yaml b/examples/comparison/auth-users/v0.1.yaml index 1c828003..7cea9886 100644 --- a/examples/comparison/auth-users/v0.1.yaml +++ b/examples/comparison/auth-users/v0.1.yaml @@ -16,16 +16,6 @@ mongo: uri: mongodb://localhost:27017 database: myapp -include: - - comments.yaml # This yaml is merged into the root level, allowing it to define its own collections and operations while still being part of the same API version. - # CANNOT OVERWRITE EXISTING KEYS (e.g. collections.users.type is already defined as 'auth' here, so it cannot be redefined in comments.yaml) - -import: - repositories: - - ./custom/post_queries.py # @repository_op: DB queries (by-author, search, etc.) - services: - - ./custom/post_actions.py # @step: business logic (publish, archive, etc.) - collections: users: type: auth @@ -39,8 +29,8 @@ collections: update: authenticated replace: authenticated delete: - - principal: identity:users - when: "principal.sub == document.id" + - principal: "users:*" + when: "principal.sub equals document.id" fields: id: type: id @@ -64,10 +54,10 @@ collections: get: public update: authenticated delete: - - principal: identity:users - when: "principal.sub == document.authorId" - - principal: identity:users - when: "principal.record.role == 'moderator'" + - principal: "users:*" + when: "principal.sub equals document.authorId" + - principal: "users:*" + when: "principal.record.role equals 'moderator'" fields: id: type: id @@ -79,16 +69,6 @@ collections: authorId: type: ref ref: users - operations: - my-posts: - path: /posts/mine - access: authenticated - # Is this a DB query (repository) or business logic (service)? - # From the YAML alone, impossible to tell. - # The decorator in custom/posts.py decides, but the YAML is blind to it. - publish: - path: /posts/{id}/publish - access: authenticated - # Same ambiguity. Is publish a simple field update (repo)? - # Or does it send notifications, validate state, etc. (service)? - # The YAML treats both identically. + # v0.1 has no custom operations: "my posts" and "publish" cannot be declared, + # and the YAML could not say whether each is a DB query or business logic. + # v0.2 separates them into repository operations and service handlers. diff --git a/examples/comparison/auth-users/v0.2.yaml b/examples/comparison/auth-users/v0.2.yaml index 8ed3af34..e370b9c1 100644 --- a/examples/comparison/auth-users/v0.2.yaml +++ b/examples/comparison/auth-users/v0.2.yaml @@ -13,7 +13,7 @@ api: delete: true imports: - posts: myapp.handlers.posts + posts: post_actions mongo: uri: mongodb://localhost:27017 @@ -22,9 +22,10 @@ mongo: entities: user: type: auth - via: - - provider: password - identifier: email + auth: + via: + - provider: password + identifier: email fields: id: type: id @@ -53,36 +54,42 @@ entities: authorId: type: ref ref: user - operations: - replace: true # Override default to allow full replacement of post documents. + published: + type: boolean + default: false -# by-author is a DB query — it belongs here. -# Registered by @repository_op("posts", "by-author") in custom/posts.py. +# by_author is a DB query — it belongs here. repositories: users: entity: user posts: entity: post + operations: + by_author: + query: + command: find + filter: { authorId: "{{authorId}}" } -# publish-post is business logic — it belongs here. +# publish_post is business logic — it belongs here. services: register: { repository: users, operation: create } login: repository: users - list-users: { repository: users, operation: list } - get-user: { repository: users, operation: get } - update-user: { repository: users, operation: update } - delete-user: { repository: users, operation: delete } + list_users: { repository: users, operation: list } + get_user: { repository: users, operation: get } + update_user: { repository: users, operation: update } + delete_user: { repository: users, operation: delete } - list-posts: { repository: posts, operation: list } - get-post: { repository: posts, operation: get } - create-post: { repository: posts, operation: create } - update-post: { repository: posts, operation: update } - delete-post: { repository: posts, operation: delete } - my-posts: { repository: posts, operation: by-author } - publish-post: + list_posts: { repository: posts, operation: list } + get_post: { repository: posts, operation: get } + create_post: { repository: posts, operation: create } + update_post: { repository: posts, operation: update } + replace_post: { repository: posts, operation: replace } + delete_post: { repository: posts, operation: delete } + posts_by_author: { repository: posts, operation: by_author } + publish_post: handler: posts.publish controllers: @@ -95,37 +102,38 @@ controllers: users: base_path: /users routes: - list: { service: list-users, method: GET, path: /, access: authenticated } - get: { service: get-user, method: GET, path: "/{id}", access: authenticated } - update: { service: update-user, method: PATCH, path: "/{id}", access: authenticated } + list: { service: list_users, method: GET, path: /, access: "*" } + get: { service: get_user, method: GET, path: "/{id}", access: "*" } + update: { service: update_user, method: PATCH, path: "/{id}", access: "*" } delete: - service: delete-user + service: delete_user method: DELETE path: /{id} access: - given: - principal: identity:users + principal: identity:user document: users when: principal.sub equals document.id posts: base_path: /posts routes: - list: { service: list-posts, method: GET, path: /, access: public } - get: { service: get-post, method: GET, path: "/{id}", access: public } - create: { service: create-post, method: POST, path: /, access: public } - update: { service: update-post, method: PATCH, path: "/{id}", access: authenticated } - my-posts: { service: my-posts, method: GET, path: /mine, access: authenticated } - publish: { service: publish-post, method: POST, path: "/{id}/publish", access: authenticated } + list: { service: list_posts, method: GET, path: /, access: public } + get: { service: get_post, method: GET, path: "/{id}", access: public } + create: { service: create_post, method: POST, path: /, access: public } + update: { service: update_post, method: PATCH, path: "/{id}", access: "*" } + replace: { service: replace_post, method: PUT, path: "/{id}", access: "*" } + by-author: { service: posts_by_author, method: GET, path: "/by-author/{authorId}", access: "*" } + publish: { service: publish_post, method: POST, path: "/{id}/publish", access: "*" } delete: - service: delete-post + service: delete_post method: DELETE path: /{id} access: - given: - principal: identity:users + principal: identity:user document: posts when: principal.sub equals document.authorId - given: - principal: identity:users + principal: identity:user when: principal.record.role equals 'moderator' diff --git a/examples/comparison/blog-proposed.yaml b/examples/comparison/blog-proposed.yaml index 5a6a4726..df829f0e 100644 --- a/examples/comparison/blog-proposed.yaml +++ b/examples/comparison/blog-proposed.yaml @@ -2,7 +2,7 @@ version: "0.2" api: operations: - defaults: [list, get, create, update, delete] + defaults: { list: true, get: true, create: true, update: true, delete: true } # Entities: data shape only. No operations, no HTTP. entities: @@ -47,9 +47,10 @@ repositories: posts: entity: post operations: - by-author: - base: list - filter: { authorId: $param.authorId } + by_author: + query: + command: find + filter: { authorId: "{{authorId}}" } users: entity: user @@ -57,34 +58,34 @@ repositories: # Services: business operations. Each one delegates to a repo op or runs custom logic. # Controllers can only reference services, never repositories directly. services: - list-posts: { repository: posts, operation: list } - get-post: { repository: posts, operation: get } - create-post: { repository: posts, operation: create } - update-post: { repository: posts, operation: update } - delete-post: { repository: posts, operation: delete } + list_posts: { repository: posts, operation: list } + get_post: { repository: posts, operation: get } + create_post: { repository: posts, operation: create } + update_post: { repository: posts, operation: update } + delete_post: { repository: posts, operation: delete } - list-users: { repository: users, operation: list } - get-user: { repository: users, operation: get } - create-user: { repository: users, operation: create } - update-user: { repository: users, operation: update } - delete-user: { repository: users, operation: delete } + list_users: { repository: users, operation: list } + get_user: { repository: users, operation: get } + create_user: { repository: users, operation: create } + update_user: { repository: users, operation: update } + delete_user: { repository: users, operation: delete } # Controllers: HTTP binding. References services only. controllers: posts: base_path: /posts routes: - list: { service: list-posts, method: GET, path: /, access: public } - get: { service: get-post, method: GET, path: /{id}, access: public } - create: { service: create-post, method: POST, path: /, access: public } - update: { service: update-post, method: PATCH, path: /{id}, access: public } - delete: { service: delete-post, method: DELETE, path: /{id}, access: public } + list: { service: list_posts, method: GET, path: /, access: public } + get: { service: get_post, method: GET, path: "/{id}", access: public } + create: { service: create_post, method: POST, path: /, access: public } + update: { service: update_post, method: PATCH, path: "/{id}", access: public } + delete: { service: delete_post, method: DELETE, path: "/{id}", access: public } users: base_path: /users routes: - list: { service: list-users, method: GET, path: /, access: public } - get: { service: get-user, method: GET, path: /{id}, access: public } - create: { service: create-user, method: POST, path: /, access: public } - update: { service: update-user, method: PATCH, path: /{id}, access: public } - delete: { service: delete-user, method: DELETE, path: /{id}, access: public } + list: { service: list_users, method: GET, path: /, access: public } + get: { service: get_user, method: GET, path: "/{id}", access: public } + create: { service: create_user, method: POST, path: /, access: public } + update: { service: update_user, method: PATCH, path: "/{id}", access: public } + delete: { service: delete_user, method: DELETE, path: "/{id}", access: public } diff --git a/examples/packs/admin/pack.yaml b/examples/packs/admin/pack.yaml index f9395086..3905bdb0 100644 --- a/examples/packs/admin/pack.yaml +++ b/examples/packs/admin/pack.yaml @@ -11,6 +11,8 @@ scaffold: - tsconfig.json - index.html - main + - config.ts + - records.ts - App - AuthProvider - CollectionPage diff --git a/examples/packs/admin/templates/lib/api.jinja b/examples/packs/admin/templates/lib/api.jinja index d3bc3061..54906d17 100644 --- a/examples/packs/admin/templates/lib/api.jinja +++ b/examples/packs/admin/templates/lib/api.jinja @@ -1,17 +1,6 @@ import { getAuthToken } from "../AuthProvider" -const BASE_URL = "{{ context.base_url }}" - -function authHeaders(): Record { - const headers: Record = { "Content-Type": "application/json" } +export function authHeaders(): Record { const token = getAuthToken() - if (token) headers["Authorization"] = `Bearer ${token}` - return headers -} - -export async function apiFetch(path: string, options?: RequestInit): Promise { - return fetch(`${BASE_URL}${path}`, { - ...options, - headers: { ...authHeaders(), ...(options?.headers as Record || {}) }, - }) + return { "Content-Type": "application/json", ...(token && { Authorization: `Bearer ${token}` }) } } diff --git a/examples/packs/admin/templates/scaffold/App.jinja b/examples/packs/admin/templates/scaffold/App.jinja index f02ef98e..6af932ff 100644 --- a/examples/packs/admin/templates/scaffold/App.jinja +++ b/examples/packs/admin/templates/scaffold/App.jinja @@ -3,14 +3,17 @@ import { BrowserRouter, Routes, Route, Navigate, useLocation, useNavigate } from import { AuthProvider, useAuth } from "./AuthProvider" import { CollectionPage } from "./CollectionPage" import { LoginPage } from "./LoginPage" -import { apiFetch } from "./lib/api" +import { authHeaders } from "./lib/api" +import { callOperation } from "./records" const ALL_COLLECTIONS = [ - {% for entity_name, entity in entities.items() %} - { key: "{{ entity.repository_name }}", label: "{{ entity_name | pascal_case }}", is_auth: {{ 'true' if entity.is_auth else 'false' }} }, + {% for entity_name, entity in entities.items() if "list" in entity.operations %} + { key: "{{ entity_name }}", path: "/{{ entity.repository_name }}", label: "{{ entity_name | pascal_case }}", is_auth: {{ 'true' if entity.is_auth else 'false' }} }, {% endfor %} ] +const SIGNS_IN = {{ (context.entities.values() | selectattr("serves_login") | list | length > 0) | tojson }} + const COLLECTION_ICONS: Record = { default: "M3.375 3C2.339 3 1.5 3.84 1.5 4.875v.75c0 1.036.84 1.875 1.875 1.875h15.75c1.035 0 1.875-.84 1.875-1.875v-.75C21 3.839 20.16 3 19.125 3H3.375zM1.5 9.75A.75.75 0 012.25 9h15a.75.75 0 010 1.5h-15A.75.75 0 011.5 9.75zm0 3A.75.75 0 012.25 12h9a.75.75 0 010 1.5h-9A.75.75 0 011.5 12.75zM3 15.75A.75.75 0 013.75 15h4.5a.75.75 0 010 1.5h-4.5A.75.75 0 013 15.75z", } @@ -78,7 +81,7 @@ function Sidebar({ accessible, onSignIn }: { accessible: Set; onSignIn: Sign out - ) : ( + ) : SIGNS_IN && ( + {can("create") && ( + + )} + {can("delete") && ( + + )} ))} diff --git a/examples/packs/admin/templates/scaffold/LoginPage.jinja b/examples/packs/admin/templates/scaffold/LoginPage.jinja index 8c0af182..df523bf3 100644 --- a/examples/packs/admin/templates/scaffold/LoginPage.jinja +++ b/examples/packs/admin/templates/scaffold/LoginPage.jinja @@ -1,9 +1,8 @@ import { useState } from "react" import { useAuth } from "./AuthProvider" -{% set auth_entity_name = context.identity_entities[0] if context.identity_entities else "user" %} -{% set auth_entity = context.entities.get(auth_entity_name) %} -{% set identifier = auth_entity.auth_identifier if auth_entity and auth_entity.auth_identifier else "email" %} +{% set identity = context.entities.values() | selectattr("serves_login") | first %} +{% set identifier = identity.auth_identifier if identity else "email" %} export function LoginPage({ onClose }: { onClose?: () => void } = {}) { const [error, setError] = useState(null) diff --git a/examples/packs/admin/templates/scaffold/RecordModal.jinja b/examples/packs/admin/templates/scaffold/RecordModal.jinja index baf5f812..885fd217 100644 --- a/examples/packs/admin/templates/scaffold/RecordModal.jinja +++ b/examples/packs/admin/templates/scaffold/RecordModal.jinja @@ -1,5 +1,6 @@ import { useState, useEffect } from "react" -import { apiFetch } from "./lib/api" +import { authHeaders } from "./lib/api" +import { callOperation } from "./records" import { COLLECTION_SCHEMAS } from "./CollectionPage" interface RecordModalProps { @@ -13,7 +14,7 @@ export function RecordModal({ collection, record, onClose, onSaved }: RecordModa const schema = COLLECTION_SCHEMAS[collection] const pk = schema?.pk || "id" const isEdit = !!record - const editableFields = (schema?.fields || []).filter(f => f.type !== "id") + const editableFields = (schema?.fields || []).filter(f => !f.readonly) const initialValues = () => { const vals: Record = {} @@ -48,11 +49,9 @@ export function RecordModal({ collection, record, onClose, onSaved }: RecordModa } try { - const path = isEdit - ? `/${collection}/${record[pk]}` - : `/${collection}` - const res = await apiFetch(path, { - method: isEdit ? "PATCH" : "POST", + const res = await callOperation(collection, isEdit ? schema.edit! : "create", { + id: isEdit ? record[pk] : undefined, + headers: authHeaders(), body: JSON.stringify(body), }) if (!res.ok) { diff --git a/examples/packs/blog/pack.yaml b/examples/packs/blog/pack.yaml index 0f6465af..ac265c01 100644 --- a/examples/packs/blog/pack.yaml +++ b/examples/packs/blog/pack.yaml @@ -6,7 +6,7 @@ framework: react components: grid: props: - collection: { type: collection, required: true } + collection: { type: projection, required: true } item: { type: component } columns: { type: number, default: 3 } card: @@ -19,9 +19,10 @@ components: colors: { type: object } article: props: - collection: { type: collection, required: true } + collection: { type: projection, required: true } title: { required: true } body: { required: true } + author: {} form: props: collection: { type: collection, required: true } @@ -35,6 +36,8 @@ scaffold: - tsconfig.json - index.html - main + - config.ts + - records.ts - App - AuthProvider - index.css diff --git a/examples/packs/blog/templates/components/article.jinja b/examples/packs/blog/templates/components/article.jinja index db7806b4..f211c2b1 100644 --- a/examples/packs/blog/templates/components/article.jinja +++ b/examples/packs/blog/templates/components/article.jinja @@ -1,6 +1,7 @@ {% set col_name = props.collection.value if props.collection else "items" %} {% set col_ref = col_name.split("[")[0] if "[" in col_name else col_name %} {% set lookup_field = col_name.split("[")[1].rstrip("]") if "[" in col_name else "id" %} +{% set dated = collection.fields.values() | selectattr("resolved_type", "equalto", "datetime") | first %} import { useParams, Link } from "react-router-dom" import { use{{ col_ref | pascal_case }} } from "@/__generated__/hooks/use{{ col_ref | pascal_case }}" @@ -25,7 +26,7 @@ function estimateReadingTime(text: string) { } export function {{ view.name | pascal_case }}Page() { - const { {{ lookup_field }} } = useParams() + const { {{ lookup_field }} } = useParams() as { {{ lookup_field }}: string } const { data: doc, loading, error } = use{{ col_ref | pascal_case }}({{ lookup_field }}) if (loading) { @@ -78,17 +79,15 @@ export function {{ view.name | pascal_case }}Page() {
- {% if collection %}
{% for field_name, field in collection.fields.items() %} {% if field.resolved_type == "enum" %} - {doc.{{ field.source_field_path[0] }}} + {doc.{{ field_name }}} {% endif %} {% endfor %}
- {% endif %} {% if props.title and props.title.kind == "ref" %}

@@ -97,15 +96,20 @@ export function {{ view.name | pascal_case }}Page() { {% endif %}
+ {% if props.author and props.author.kind == "ref" %}
- {(doc.authorName || "A").charAt(0).toUpperCase()} + {String(doc.{{ props.author.value }} || "A").charAt(0).toUpperCase()}
+ {% endif %}
-

{doc.authorName || "Anonymous"}

+ {% if props.author and props.author.kind == "ref" %} +

{doc.{{ props.author.value }} || "Anonymous"}

+ {% endif %}
- + {% if dated %} + + {% endif %} {% if props.body and props.body.kind == "ref" %} - {estimateReadingTime(doc.{{ props.body.value }})} {% endif %}
diff --git a/examples/packs/blog/templates/components/auth.jinja b/examples/packs/blog/templates/components/auth.jinja index 3a49f2ea..0137a245 100644 --- a/examples/packs/blog/templates/components/auth.jinja +++ b/examples/packs/blog/templates/components/auth.jinja @@ -54,8 +54,9 @@ export function {{ view.name | pascal_case }}Page() {
{% if entity %} - {% for field_name, field in entity.fields.items() %} - {% if field.type != "id" and field.type != "datetime" and field.type != "ref" and (field.default is none or field.required) %} + {% for field in entity.signup_fields %} + {% set field_name = field.name %} + {% if field.type not in ("datetime", "ref") %} {% if field_name == entity.auth_identifier %}
); -} \ No newline at end of file +} + +export default LoadingSpinner; diff --git a/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/auth-hero-split.jinja b/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/auth-hero-split.jinja index 3ff1da34..420ce7b8 100644 --- a/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/auth-hero-split.jinja +++ b/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/auth-hero-split.jinja @@ -1,48 +1,20 @@ -{% set identifier = (entity.auth_identifier if entity else none) or 'email' %} -{% set pk = (entity.pk_field if entity else 'id') %} -{% set login_fields = [] %} -{% set register_fields = [] %} -{% if entity %} -{% for f in entity.fields.values() if f.name != pk and not f.readonly %} -{% if f.name == identifier %} -{% set _ = login_fields.insert(0, f) %} -{% set _ = register_fields.insert(0, f) %} -{% else %} -{% set _ = register_fields.append(f) %} -{% endif %} -{% endfor %} -{% endif %} +{% from 'macros/field_input.jinja' import field_input %} +{% set identifier = entity.auth_identifier %} +{% set login_fields = [identifier] + (entity.credential_fields | map(attribute='name') | list) %} import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { useAuth, ExternalProviders } from '../__generated__/App'; -import { API_BASE_URL } from '../__generated__/config'; +import { SIGNUP_FIELDS, formValues, payload } from '../__generated__/fields'; +import { callOperation, failure } from '../__generated__/records'; -{% macro input_type(field) %}{% if field.type == 'number' %}number{% elif field.type == 'datetime' %}datetime-local{% elif field.type == 'boolean' %}checkbox{% elif field.name == identifier %}{{ identifier }}{% else %}text{% endif %}{% endmacro %} - -{% macro field_input(field) -%} -
- - setFormData({ ...formData, {{ field.name }}: e.target.value })} - {{ 'required' if field.required else '' }} - style={{ "{{" }} border: '1.5px solid var(--wf-line-strong)', borderRadius: 5, background: 'var(--wf-surface)', padding: '0 12px', minHeight: 36, fontSize: 14, color: 'var(--wf-ink)', outline: 'none' {{ "}}" }} - /> -
-{%- endmacro %} +const FIELDS = SIGNUP_FIELDS[{{ entity.name | tojson }}]; export default function Login() { const [mode, setMode] = useState<'login' | 'register'>('login'); const [error, setError] = useState(''); - const [formData, setFormData] = useState({ - {% for f in register_fields %}{{ f.name }}: '', - {% endfor %}password: '', - confirmPassword: '', - }); + const [values, setValues] = useState(() => formValues(FIELDS)); + const [confirmPassword, setConfirmPassword] = useState(''); + const setValue = (name: string, value: unknown) => setValues(prev => ({ ...prev, [name]: value })); const { login } = useAuth(); const navigate = useNavigate(); @@ -51,24 +23,19 @@ export default function Login() { setError(''); try { if (mode === 'register') { - if (formData.password !== formData.confirmPassword) { + if (values.password !== confirmPassword) { setError('Passwords do not match'); return; } - const { confirmPassword, ...submitData } = formData; - const res = await fetch(`${API_BASE_URL}/{{ identity_collection or "users" }}/register`, { - method: 'POST', + const res = await callOperation('{{ entity.name }}', 'register', { headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(submitData), + body: JSON.stringify(payload(FIELDS, values)), }); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - throw new Error(data.detail || 'Registration failed'); - } + if (!res.ok) throw await failure(res, 'Registration failed'); setMode('login'); return; } - await login(formData.{{ identifier }}, formData.password); + await login(String(values[{{ identifier | tojson }}]), String(values.password)); navigate('/'); } catch (err: any) { setError(err.message || `${mode === 'login' ? 'Login' : 'Registration'} failed`); @@ -114,31 +81,11 @@ export default function Login() {
- {/* Login fields (always visible) */} - {% for field in login_fields %} -{{ field_input(field) }} - {% endfor %} - - {/* Register-only fields */} - {% for field in register_fields if field.name != identifier %} - {mode === 'register' && ( -{{ field_input(field) }} - )} - {% endfor %} - -
- - setFormData({ ...formData, password: e.target.value })} - required - style={{ "{{" }} border: '1.5px solid var(--wf-line-strong)', borderRadius: 5, background: 'var(--wf-surface)', padding: '0 12px', minHeight: 36, fontSize: 14, color: 'var(--wf-ink)', outline: 'none' {{ "}}" }} - /> -
+{% for field in entity.signup_fields %} + {% if field.name not in login_fields %}{mode === 'register' && ({% endif %} + {{ field_input(field, id_prefix='login-') }} + {% if field.name not in login_fields %})}{% endif %} +{% endfor %} {mode === 'register' && (
@@ -148,8 +95,8 @@ export default function Login() { setFormData({ ...formData, confirmPassword: e.target.value })} + value={confirmPassword} + onChange={e => setConfirmPassword(e.target.value)} required style={{ "{{" }} border: '1.5px solid var(--wf-line-strong)', borderRadius: 5, background: 'var(--wf-surface)', padding: '0 12px', minHeight: 36, fontSize: 14, color: 'var(--wf-ink)', outline: 'none' {{ "}}" }} /> @@ -164,8 +111,9 @@ export default function Login() { - + + {% if 'register' in entity.routes %}
{mode === 'login' ? 'New here? ' : 'Already have an account? '}
+ {% endif %}
diff --git a/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/auth-tabbed.jinja b/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/auth-tabbed.jinja index c19fc57f..8bd765ea 100644 --- a/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/auth-tabbed.jinja +++ b/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/auth-tabbed.jinja @@ -27,13 +27,14 @@ export function AuthTabbed({ } }; - const handleFieldChange = (e: React.ChangeEvent) => { + const handleFieldChange = (e: React.ChangeEvent) => { setFormData({ ...formData, [e.target.name]: e.target.value }); }; return (
+

{appName}

{/* Tabs */}
@@ -127,3 +128,5 @@ export function AuthTabbed({
); } + +export default AuthTabbed; diff --git a/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/bulk-edit-drawer.jinja b/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/bulk-edit-drawer.jinja index e4e480e8..86198eb0 100644 --- a/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/bulk-edit-drawer.jinja +++ b/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/bulk-edit-drawer.jinja @@ -1,7 +1,12 @@ +{% from 'macros/access.jinja' import allowed %} +{% from 'macros/field_input.jinja' import field_control, control_imports %} +{% set editable_fields = entity.fields.values() | rejectattr('name', 'equalto', entity.pk_field) | rejectattr('readonly') | selectattr('name', 'in', props.fields.value if props.fields else entity.fields) | list %} import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { useAuth } from '../__generated__/App'; -import { API_BASE_URL } from '../__generated__/config'; +import { Allowed, useAuth } from '../__generated__/App'; +import { callOperation, displayValue, listRecords } from '../__generated__/records'; +import { ENTITY_FIELDS, FormValues, formValues, payload } from '../__generated__/fields'; +{{ control_imports(editable_fields) }} interface {{ entity.name | pascal_case }} { {{ entity.pk_field }}: string; @@ -10,7 +15,6 @@ interface {{ entity.name | pascal_case }} { {% endfor %} } -{% set editable_fields = entity.fields.values() | rejectattr('name', 'equalto', entity.pk_field) | rejectattr('readonly') | list %} {% set entity_plural = entity.name | lower + 's' %} {% set roles = props.field_roles.value if props.field_roles is defined else {} %} {% set role_names = roles.values() | list %} @@ -21,9 +25,7 @@ interface {{ entity.name | pascal_case }} { {% set other_fields = editable_fields | rejectattr('name', 'in', role_names) | list %} {% set display_fields = (role_fields + other_fields)[:4] %} -interface BulkPatch { - [field: string]: string; -} +const FIELDS = ENTITY_FIELDS[{{ entity.name | tojson }}]; export default function {{ view.name | pascal_case }}() { const [items, setItems] = useState<{{ entity.name | pascal_case }}[]>([]); @@ -32,7 +34,7 @@ export default function {{ view.name | pascal_case }}() { const [selectedIds, setSelectedIds] = useState>(new Set()); const [drawerOpen, setDrawerOpen] = useState(false); const [enabledFields, setEnabledFields] = useState>(new Set()); - const [patch, setPatch] = useState({}); + const [patch, setPatch] = useState(() => formValues(FIELDS)); const [applying, setApplying] = useState(false); const { token } = useAuth(); const navigate = useNavigate(); @@ -41,12 +43,8 @@ export default function {{ view.name | pascal_case }}() { const headers: Record = {}; if (token) headers['Authorization'] = `Bearer ${token}`; - fetch(`${API_BASE_URL}/{{ entity.repository_name | kebab_case }}`, { headers }) - .then(res => { - if (!res.ok) throw new Error(res.status === 401 ? 'Authentication required' : `Error: ${res.status}`); - return res.json(); - }) - .then(data => { setItems(Array.isArray(data) ? data : []); setLoading(false); }) + listRecords('{{ entity.name }}', headers) + .then(data => { setItems(data); setLoading(false); }) .catch(err => { setError(err.message); setLoading(false); }); }, [token]); @@ -70,34 +68,28 @@ export default function {{ view.name | pascal_case }}() { return next; }); + const setPatchValue = (name: string, value: unknown) => setPatch(prev => ({ ...prev, [name]: value })); + const applyBulkEdit = async () => { - const activePatch = Object.fromEntries( - Object.entries(patch).filter(([k]) => enabledFields.has(k)) - ); - if (Object.keys(activePatch).length === 0) return; + const enabled = Object.fromEntries(Object.entries(FIELDS).filter(([name]) => enabledFields.has(name))); + const body = JSON.stringify(payload(enabled, patch, {})); + if (body === '{}') return; setApplying(true); const headers: Record = { 'Content-Type': 'application/json' }; if (token) headers['Authorization'] = `Bearer ${token}`; - await Promise.all([...selectedIds].map(id => - fetch(`${API_BASE_URL}/{{ entity.repository_name | kebab_case }}/${id}`, { - method: 'PATCH', - headers, - body: JSON.stringify(activePatch), - }) + const updated: {{ entity.name | pascal_case }}[] = await Promise.all([...selectedIds].map(id => + callOperation('{{ entity.name }}', 'update', { id, headers, body }) + .then(res => (res.ok ? res.json() : null)) )); + const updatedById = new Map(updated.filter(Boolean).map(item => [item.{{ entity.pk_field }}, item])); - // Optimistically update local state - setItems(prev => prev.map(item => - selectedIds.has(item.{{ entity.pk_field }}) - ? { ...item, ...activePatch } - : item - )); + setItems(prev => prev.map(item => updatedById.get(item.{{ entity.pk_field }}) ?? item)); setApplying(false); setDrawerOpen(false); setEnabledFields(new Set()); - setPatch({}); + setPatch(formValues(FIELDS)); setSelectedIds(new Set()); }; @@ -135,12 +127,14 @@ export default function {{ view.name | pascal_case }}() { Bulk edit {selectedIds.size} )} + {% call allowed(entity, 'create') %} + {% endcall %}
{/* Grid: table + drawer */} @@ -185,9 +179,9 @@ export default function {{ view.name | pascal_case }}() { {% for field in display_fields %} {% if field.type == 'enum' %} - {String(item.{{ field.name }} ?? '—')} + {displayValue(item.{{ field.name }})} {% else %} - {String(item.{{ field.name }} ?? '—')} + {displayValue(item.{{ field.name }})} {% endif %} {% endfor %} @@ -228,26 +222,9 @@ export default function {{ view.name | pascal_case }}() { {{ field.name | title }} {enabledFields.has('{{ field.name }}') && ( - {% if field.type == 'enum' and field.options %} - - {% else %} - setPatch(p => ({ ...p, '{{ field.name }}': e.target.value }))} - placeholder="New value..." - type="{% if field.type == 'number' %}number{% elif field.type == 'boolean' %}text{% else %}text{% endif %}" - className="w-full wf-input text-sm" - /> - {% endif %} +
+ {{ field_control(field, 'patch', 'setPatchValue', 'bulk-' ~ field.name) }} +
)}
{% endfor %} diff --git a/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/bulk-floating.jinja b/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/bulk-floating.jinja index f93be955..89335cb0 100644 --- a/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/bulk-floating.jinja +++ b/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/bulk-floating.jinja @@ -1,7 +1,11 @@ +{% from 'macros/access.jinja' import allowed %} import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { useAuth } from '../__generated__/App'; -import { API_BASE_URL } from '../__generated__/config'; +import { Allowed, useAuth } from '../__generated__/App'; +import { {{ 'callOperation, ' if 'delete' in props.actions.value }}displayValue, listRecords } from '../__generated__/records'; +{% if 'export' in props.actions.value %} +import { exportRows } from '../__generated__/listing'; +{% endif %} interface {{ entity.name | pascal_case }} { {{ entity.pk_field }}: string; @@ -32,12 +36,8 @@ export default function {{ view.name | pascal_case }}() { const headers: Record = {}; if (token) headers['Authorization'] = `Bearer ${token}`; - fetch(`${API_BASE_URL}/{{ entity.repository_name | kebab_case }}`, { headers }) - .then(res => { - if (!res.ok) throw new Error(res.status === 401 ? 'Authentication required' : `Error: ${res.status}`); - return res.json(); - }) - .then(data => { setItems(Array.isArray(data) ? data : []); setLoading(false); }) + listRecords('{{ entity.name }}', headers) + .then(data => { setItems(data); setLoading(false); }) .catch(err => { setError(err.message); setLoading(false); }); }, [token]); @@ -54,17 +54,19 @@ export default function {{ view.name | pascal_case }}() { : new Set(items.map(i => i.{{ entity.pk_field }})) ); + {% if 'delete' in props.actions.value %} const handleBulkDelete = async () => { if (!confirm(`Delete ${selectedIds.size} selected records?`)) return; const headers: Record = { 'Content-Type': 'application/json' }; if (token) headers['Authorization'] = `Bearer ${token}`; await Promise.all([...selectedIds].map(id => - fetch(`${API_BASE_URL}/{{ entity.repository_name | kebab_case }}/${id}`, { method: 'DELETE', headers }) + callOperation('{{ entity.name }}', 'delete', { id, headers }) )); setItems(prev => prev.filter(i => !selectedIds.has(i.{{ entity.pk_field }}))); setSelectedIds(new Set()); }; + {% endif %} if (loading) { return ( @@ -92,12 +94,14 @@ export default function {{ view.name | pascal_case }}() {

{{ entity.name | title }}

{items.length} records
+ {% call allowed(entity, 'create') %} + {% endcall %}
{/* Table */} @@ -145,9 +149,9 @@ export default function {{ view.name | pascal_case }}() { {% for field in display_fields %} {% if field.type == 'enum' %} - {String(item.{{ field.name }} ?? '—')} + {displayValue(item.{{ field.name }})} {% else %} - {String(item.{{ field.name }} ?? '—')} + {displayValue(item.{{ field.name }})} {% endif %} {% endfor %} @@ -169,27 +173,16 @@ export default function {{ view.name | pascal_case }}() { + {% if 'export' in props.actions.value %} - - - - - + {% endif %} + {% if 'delete' in props.actions.value %} + {% call allowed(entity, 'delete') %} + {% endcall %} + {% endif %} diff --git a/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/bulk-progress.jinja b/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/bulk-progress.jinja index 485a722e..0694b4c7 100644 --- a/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/bulk-progress.jinja +++ b/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/bulk-progress.jinja @@ -1,7 +1,8 @@ +{% from 'macros/access.jinja' import allowed %} import { useEffect, useState, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; -import { useAuth } from '../__generated__/App'; -import { API_BASE_URL } from '../__generated__/config'; +import { Allowed, useAuth } from '../__generated__/App'; +import { callOperation, displayValue, listRecords } from '../__generated__/records'; interface {{ entity.name | pascal_case }} { {{ entity.pk_field }}: string; @@ -23,9 +24,11 @@ interface {{ entity.name | pascal_case }} { type OperationState = | { status: 'idle' } | { status: 'running'; label: string; total: number; done: number; cancelled: boolean; cancel: () => void } - | { status: 'done'; label: string; total: number; undoable: boolean } + | { status: 'done'; label: string; total: number } | { status: 'cancelled'; label: string; done: number }; +const ACTION = {{ props.action.value | tojson }}; + export default function {{ view.name | pascal_case }}() { const [items, setItems] = useState<{{ entity.name | pascal_case }}[]>([]); const [loading, setLoading] = useState(true); @@ -40,12 +43,8 @@ export default function {{ view.name | pascal_case }}() { const headers: Record = {}; if (token) headers['Authorization'] = `Bearer ${token}`; - fetch(`${API_BASE_URL}/{{ entity.repository_name | kebab_case }}`, { headers }) - .then(res => { - if (!res.ok) throw new Error(res.status === 401 ? 'Authentication required' : `Error: ${res.status}`); - return res.json(); - }) - .then(data => { setItems(Array.isArray(data) ? data : []); setLoading(false); }) + listRecords('{{ entity.name }}', headers) + .then(data => { setItems(data); setLoading(false); }) .catch(err => { setError(err.message); setLoading(false); }); }, [token]); @@ -62,7 +61,7 @@ export default function {{ view.name | pascal_case }}() { : new Set(items.map(i => i.{{ entity.pk_field }})) ); - const runBulkArchive = async () => { + const runBulk = async () => { const ids = [...selectedIds]; const total = ids.length; abortRef.current = new AbortController(); @@ -71,7 +70,7 @@ export default function {{ view.name | pascal_case }}() { let done = 0; setOperation({ status: 'running', - label: 'Archiving', + label: ACTION, total, done: 0, cancelled: false, @@ -84,12 +83,8 @@ export default function {{ view.name | pascal_case }}() { for (const id of ids) { if (signal.aborted) break; try { - await fetch(`${API_BASE_URL}/{{ entity.repository_name | kebab_case }}/${id}`, { - method: 'PATCH', - headers, - body: JSON.stringify({ status: 'archived' }), - signal, - }); + const res = await callOperation('{{ entity.name }}', 'delete', { id, headers, signal }); + if (!res.ok) break; } catch (_) { // abort or network error — stop gracefully break; @@ -100,10 +95,10 @@ export default function {{ view.name | pascal_case }}() { ); } - if (signal.aborted) { - setOperation({ status: 'cancelled', label: 'Archive', done }); + if (done < total) { + setOperation({ status: 'cancelled', label: ACTION, done }); } else { - setOperation({ status: 'done', label: 'Archived', total, undoable: true }); + setOperation({ status: 'done', label: ACTION, total }); setItems(prev => prev.filter(i => !selectedIds.has(i.{{ entity.pk_field }}))); setSelectedIds(new Set()); } @@ -138,16 +133,18 @@ export default function {{ view.name | pascal_case }}() { {items.length} records
{selectedIds.size > 0 && operation.status === 'idle' && ( - )} + {% call allowed(entity, 'create') %} + {% endcall %}
{/* Progress / undo banner */} @@ -156,13 +153,13 @@ export default function {{ view.name | pascal_case }}() {
{operation.status === 'running' && ( - <>Archiving {(operation as any).total} {{ entity_plural }}… + <>{ACTION}: {(operation as any).total} {{ entity_plural }}… )} {operation.status === 'done' && ( - <>{(operation as any).label} {(operation as any).total} {{ entity_plural }} + <>{(operation as any).label}: {(operation as any).total} {{ entity_plural }} done )} {operation.status === 'cancelled' && ( - <>Archive cancelled after {(operation as any).done} {{ entity_plural }} + <>{ACTION} stopped after {(operation as any).done} {{ entity_plural }} )} @@ -181,14 +178,6 @@ export default function {{ view.name | pascal_case }}() { Cancel )} - {operation.status === 'done' && (operation as any).undoable && ( - - )} - - - + {% endif %} + {% if 'delete' in props.actions.value %} + {% call allowed(entity, 'delete') %} + {% endcall %} + {% endif %} + {% endcall %} )}
@@ -202,9 +206,9 @@ export default function {{ view.name | pascal_case }}() { {% for field in display_fields %} {% if field.type == 'enum' %} - {String(item.{{ field.name }} ?? '—')} + {displayValue(item.{{ field.name }})} {% else %} - {String(item.{{ field.name }} ?? '—')} + {displayValue(item.{{ field.name }})} {% endif %} {% endfor %} diff --git a/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/command-palette.jinja b/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/command-palette.jinja index 6b5e1a9d..c4fdbbb6 100644 --- a/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/command-palette.jinja +++ b/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/command-palette.jinja @@ -1,7 +1,20 @@ +{% from 'macros/access.jinja' import allowed %} +{% set creates = 'create' in props.actions.value %} +{% set others = props.collections.value | reject('equalto', entity.name) | list %} +{% set paths = {} %} +{% for listed in context.views.values() if listed.component == 'crud' and listed.props.collection %} +{% set _ = paths.update({listed.props.collection.value: listed.path}) %} +{% endfor %} import { useEffect, useState, useRef, useMemo, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; -import { useAuth } from '../__generated__/App'; -import { API_BASE_URL } from '../__generated__/config'; +{% if creates %} +import { allows } from '../__generated__/access'; +{% endif %} +import { Allowed, useAuth } from '../__generated__/App'; +import { displayValue, listRecords } from '../__generated__/records'; +{% if others %} +import { matching } from '../__generated__/listing'; +{% endif %} interface {{ entity.name | pascal_case }} { {{ entity.pk_field }}: string; @@ -15,6 +28,15 @@ interface {{ entity.name | pascal_case }} { {% set status_field = entity.fields[roles.status] if roles.status is defined else none %} {% set date_field = entity.fields[roles.date] if roles.date is defined else none %} +{% if others %} +/** The other collections the palette searches, with the key and crud page of each. */ +const OTHERS = [ + {% for name in others %} + { name: {{ name | tojson }}, key: {{ context.entities[name].pk_field | tojson }}, path: {{ paths.get(name) | tojson }} }, + {% endfor %} +]; + +{% endif %} interface Action { id: string; label: string; @@ -30,26 +52,34 @@ export default function {{ view.name | pascal_case }}() { const [query, setQuery] = useState(''); const [cursor, setCursor] = useState(0); const inputRef = useRef(null); - const { token } = useAuth(); + const { token{{ ', caller' if creates }} } = useAuth(); const navigate = useNavigate(); + {% if others %} + const [others, setOthers] = useState[]>>({}); + {% endif %} + useEffect(() => { const headers: Record = {}; if (token) headers['Authorization'] = `Bearer ${token}`; - fetch(`${API_BASE_URL}/{{ entity.repository_name | kebab_case }}`, { headers }) - .then(res => { - if (!res.ok) throw new Error(res.status === 401 ? 'Authentication required' : `Error: ${res.status}`); - return res.json(); - }) - .then(data => { setItems(Array.isArray(data) ? data : []); setLoading(false); }) + listRecords('{{ entity.name }}', headers) + .then(data => { setItems(data); setLoading(false); }) .catch(err => { setError(err.message); setLoading(false); }); + {% if others %} + Promise.all(OTHERS.map(({ name }) => listRecords(name, headers).then(rows => [name, rows] as const))) + .then(entries => setOthers(Object.fromEntries(entries))) + .catch(err => setError(err.message)); + {% endif %} }, [token]); const actions: Action[] = useMemo(() => [ - { id: 'new', label: 'Create {{ entity.name | title }}', shortcut: 'C', handler: () => navigate('{{ view.path }}/new') }, - { id: 'settings', label: 'Settings', shortcut: ',', handler: () => {} }, - ], [navigate]); + {% if creates %} + ...(allows({{ entity.name | tojson }}, 'create', caller) + ? [{ id: 'new', label: 'Create {{ entity.name | title }}', shortcut: 'C', handler: () => navigate('{{ view.path }}/new') }] + : []), + {% endif %} + ], [navigate{{ ', caller' if creates }}]); const matchedItems = useMemo(() => query.trim() @@ -136,12 +166,14 @@ export default function {{ view.name | pascal_case }}() { Search or jump to... ⌘K + {% call allowed(entity, 'create') %} + {% endcall %}
{/* Table */} @@ -173,9 +205,9 @@ export default function {{ view.name | pascal_case }}() { {% if field.name != entity.pk_field and ns.count < 5 %} {% if field.type == 'enum' %} - {String(item.{{ field.name }} ?? '—')} + {displayValue(item.{{ field.name }})} {% else %} - {String(item.{{ field.name }} ?? '—')} + {displayValue(item.{{ field.name }})} {% endif %} {% set ns.count = ns.count + 1 %} @@ -253,6 +285,28 @@ export default function {{ view.name | pascal_case }}() { )} + {% if others %} + {query.trim() && OTHERS.map(({ name, key, path }) => { + const rows = others[name] ?? []; + const found = matching(rows, query, Object.keys(rows[0] ?? {})).slice(0, 6); + return found.length > 0 && ( +
+

{name}

+ {found.map((row) => ( + + ))} +
+ ); + })} + {% endif %} + {/* Actions */} {matchedActions.length > 0 && (
diff --git a/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/context-menu.jinja b/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/context-menu.jinja index 5ce27abe..65662953 100644 --- a/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/context-menu.jinja +++ b/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/context-menu.jinja @@ -1,7 +1,8 @@ +{% from 'macros/access.jinja' import allowed %} import { useEffect, useState, useRef, useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; -import { useAuth } from '../__generated__/App'; -import { API_BASE_URL } from '../__generated__/config'; +import { Allowed, useAuth } from '../__generated__/App'; +import { {{ 'callOperation, ' if 'delete' in props.actions.value }}displayValue, listRecords } from '../__generated__/records'; interface {{ entity.name | pascal_case }} { {{ entity.pk_field }}: string; @@ -36,12 +37,8 @@ export default function {{ view.name | pascal_case }}() { const headers: Record = {}; if (token) headers['Authorization'] = `Bearer ${token}`; - fetch(`${API_BASE_URL}/{{ entity.repository_name | kebab_case }}`, { headers }) - .then(res => { - if (!res.ok) throw new Error(res.status === 401 ? 'Authentication required' : `Error: ${res.status}`); - return res.json(); - }) - .then(data => { setItems(Array.isArray(data) ? data : []); setLoading(false); }) + listRecords('{{ entity.name }}', headers) + .then(data => { setItems(data); setLoading(false); }) .catch(err => { setError(err.message); setLoading(false); }); }, [token]); @@ -77,22 +74,26 @@ export default function {{ view.name | pascal_case }}() { : new Set(items.map(i => i.{{ entity.pk_field }})) ); + {% if 'delete' in props.actions.value %} const handleDelete = async () => { if (!confirm(`Delete ${selectedIds.size} record(s)?`)) { closeMenu(); return; } const headers: Record = { 'Content-Type': 'application/json' }; if (token) headers['Authorization'] = `Bearer ${token}`; await Promise.all([...selectedIds].map(id => - fetch(`${API_BASE_URL}/{{ entity.repository_name | kebab_case }}/${id}`, { method: 'DELETE', headers }) + callOperation('{{ entity.name }}', 'delete', { id, headers }) )); setItems(prev => prev.filter(i => !selectedIds.has(i.{{ entity.pk_field }}))); setSelectedIds(new Set()); closeMenu(); }; + {% endif %} + {% if 'copy' in props.actions.value %} const copyIds = () => { navigator.clipboard.writeText([...selectedIds].join('\n')); closeMenu(); }; + {% endif %} if (loading) { return ( @@ -125,12 +126,14 @@ export default function {{ view.name | pascal_case }}() { {n > 0 && ( {n} selected )} + {% call allowed(entity, 'create') %} + {% endcall %}
{/* Table */} @@ -179,9 +182,9 @@ export default function {{ view.name | pascal_case }}() { {% for field in display_fields %} {% if field.type == 'enum' %} - {String(item.{{ field.name }} ?? '—')} + {displayValue(item.{{ field.name }})} {% else %} - {String(item.{{ field.name }} ?? '—')} + {displayValue(item.{{ field.name }})} {% endif %} {% endfor %} @@ -206,36 +209,42 @@ export default function {{ view.name | pascal_case }}() { - {/* Primary actions */} + {% if 'open' in props.actions.value %} + {% endif %} + {% if 'edit' in props.actions.value %} + {% call allowed(entity, 'edit') %} -
- + {% endcall %} + {% endif %} + {% if 'copy' in props.actions.value %} - -
- + {% endif %} + {% if 'delete' in props.actions.value %} + {% call allowed(entity, 'delete') %} + {% endcall %} + {% endif %}
)}
diff --git a/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/delete-bulk-cascade.jinja b/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/delete-bulk-cascade.jinja index 40e4a9b4..9048bb20 100644 --- a/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/delete-bulk-cascade.jinja +++ b/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/delete-bulk-cascade.jinja @@ -1,87 +1,22 @@ -import { useState } from 'react'; - -interface CascadeRelation { - label: string; - count: number; -} - -interface DeleteBulkCascadeProps { - count: number; - entityLabel?: string; - relations: CascadeRelation[]; - onConfirm: (cascade: boolean) => void; - onCancel: () => void; - loading?: boolean; -} - -export function DeleteBulkCascade({ - count, - entityLabel = '{{ entity.name | lower | plural }}', - relations, - onConfirm, - onCancel, - loading = false, -}: DeleteBulkCascadeProps) { - const [cascade, setCascade] = useState(false); - - return ( -
-
-
-

- Delete {count} {entityLabel}? -

-

- This action is irreversible. Review what will be affected. -

- - {/* Cascade warning */} - {relations.length > 0 && ( -
-

- Related records affected -

-
    - {relations.map((rel) => ( -
  • - {rel.label} - {rel.count} -
  • - ))} -
-
- )} - - {/* Cascade checkbox */} - - -
- - -
-
-
-
- ); -} +{% from 'macros/delete.jinja' import deletion %} +{% set relations = props.relations.value if props.relations else [] %} +{% call deletion(entity, ['onConfirm']) %} +

Delete {{ props.count.value }} {{ entity.repository_name }}?

+

This action is irreversible. Review what will be affected.

+{% if relations %} +
+

Related records affected

+
    + {% for relation in relations %} +
  • {{ relation }}
  • + {% endfor %} +
+
+{% endif %} +
+ + +
+{% endcall %} diff --git a/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/delete-dialog.jinja b/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/delete-dialog.jinja index 9a3839b2..bfc6459e 100644 --- a/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/delete-dialog.jinja +++ b/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/delete-dialog.jinja @@ -42,3 +42,5 @@ export function DeleteDialog({ ); } + +export default DeleteDialog; diff --git a/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/delete-simple.jinja b/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/delete-simple.jinja index bca10c51..02836a44 100644 --- a/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/delete-simple.jinja +++ b/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/delete-simple.jinja @@ -1,45 +1,12 @@ -interface DeleteSimpleProps { - entityLabel?: string; - recordName?: string; - onConfirm: () => void; - onCancel: () => void; - loading?: boolean; -} - -export function DeleteSimple({ - entityLabel = '{{ entity.name | title }}', - recordName, - onConfirm, - onCancel, - loading = false, -}: DeleteSimpleProps) { - return ( -
-
-
-

- Delete this {entityLabel}? -

-

- {recordName ? ( - <> - {recordName} will be permanently removed. - This action cannot be undone. - - ) : ( - `This ${entityLabel} will be permanently removed. This action cannot be undone.` - )} -

-
- - -
-
-
-
- ); -} +{% from 'macros/delete.jinja' import deletion %} +{% set label = props.entity.value if props.entity else entity.name | title %} +{% call deletion(entity, ['onConfirm']) %} +

Delete this {{ label }}?

+

This {{ label }} will be permanently removed. This action cannot be undone.

+
+ + +
+{% endcall %} diff --git a/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/delete-soft.jinja b/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/delete-soft.jinja index 41b5bc20..484b8732 100644 --- a/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/delete-soft.jinja +++ b/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/delete-soft.jinja @@ -1,100 +1,23 @@ -import { useState } from 'react'; - -interface DeleteSoftProps { - entityLabel?: string; - onConfirm: (mode: 'archive' | 'delete') => void; - onCancel: () => void; - loading?: boolean; -} - -export function DeleteSoft({ - entityLabel = '{{ entity.name | title }}', - onConfirm, - onCancel, - loading = false, -}: DeleteSoftProps) { - const [mode, setMode] = useState<'archive' | 'delete'>('archive'); - - return ( -
-
-
-

Remove {entityLabel}?

-

Choose what happens to this record.

- -
- {/* Archive option */} - - - {/* Hard delete option */} - -
- -
- - -
-
-
-
- ); -} +{% from 'macros/delete.jinja' import deletion %} +{% set label = props.entity.value if props.entity else entity.name | title %} +{% call deletion(entity, ['onArchive'] if entity.removal else ['onDelete']) %} +{% if entity.removal %} +

Archive this {{ label }}?

+

The {{ label }} disappears from every list and lookup; its data stays stored.

+
+ + +
+{% else %} +

Delete this {{ label }} permanently?

+

The {{ label }} and its data are erased. This action cannot be undone.

+
+ + +
+{% endif %} +{% endcall %} diff --git a/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/delete-type-confirm.jinja b/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/delete-type-confirm.jinja index 29ebe8ad..471170a8 100644 --- a/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/delete-type-confirm.jinja +++ b/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/delete-type-confirm.jinja @@ -1,77 +1,47 @@ -import { useState } from 'react'; +{% from 'macros/delete.jinja' import deletion %} +{% set label = props.entity.value if props.entity else entity.name | title %} +{% set confirm_text = props.confirm_text.value if props.confirm_text else entity.name %} +{% call deletion(entity, ['onConfirm']) %} + + {(confirmed) => ( + <> +

Delete {{ label }} permanently?

+

This cannot be undone. All associated data will be lost.

+
+ + +
+ + )} +
+{% endcall %} -interface DeleteTypeConfirmProps { - entityLabel?: string; - recordSlug: string; - recordPreview?: Record; - onConfirm: () => void; - onCancel: () => void; - loading?: boolean; -} +import type { ReactNode } from 'react'; -export function DeleteTypeConfirm({ - entityLabel = '{{ entity.name | title }}', - recordSlug, - recordPreview, - onConfirm, - onCancel, - loading = false, -}: DeleteTypeConfirmProps) { +/** Asks for `text` to be typed before its content counts as confirmed. */ +function TypedConfirmation({ text, children }: { text: string; children: (confirmed: boolean) => ReactNode }) { const [typed, setTyped] = useState(''); - const confirmed = typed === recordSlug; - return ( -
-
-
- {/* Danger icon */} -
- ! -
- -

Delete {entityLabel} permanently?

-

- This cannot be undone. All associated data will be lost. -

- - {/* Record preview card */} - {recordPreview && ( -
- {Object.entries(recordPreview).map(([k, v]) => ( -
- {k} - {v} -
- ))} -
- )} - -

- Type {recordSlug} to confirm: -

- setTyped(e.target.value)} - placeholder={recordSlug} - className="wf-input mb-5 w-full" - autoFocus - /> - -
- - -
-
-
-
+ <> +

+ Type {text} to confirm: +

+ setTyped(e.target.value)} + placeholder={text} + className="wf-input mb-5 w-full" + autoFocus + /> + {children(typed === text)} + ); } diff --git a/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/detail-article.jinja b/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/detail-article.jinja index 2cdd5bcd..7d26e892 100644 --- a/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/detail-article.jinja +++ b/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/detail-article.jinja @@ -1,9 +1,21 @@ +{% from 'macros/access.jinja' import allowed %} +{% from 'macros/delete.jinja' import delete_page, delete_record %} +{% set roles = props.field_roles.value if props.field_roles is defined else {} %} +{% set title_field = entity.fields[roles.title] if roles.title is defined else none %} +{% set content_field = entity.fields[roles.content] if roles.content is defined else none %} +{% set image_field = entity.fields[roles.image] if roles.image is defined else none %} +{% set status_field = entity.fields[roles.status] if roles.status is defined else none %} +{% set author_field = entity.fields[roles.author] if roles.author is defined else none %} +{% set date_field = entity.fields[roles.date] if roles.date is defined else none %} import { useEffect, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; -import { useAuth } from '../__generated__/App'; -import { API_BASE_URL } from '../__generated__/config'; +{{ delete_page(pages) }} +import { Allowed, useAuth } from '../__generated__/App'; +import { callOperation, displayValue } from '../__generated__/records'; import { Button } from '../__generated__/components/Button'; +{% if status_field %} import { Badge } from '../__generated__/components/Badge'; +{% endif %} import { LoadingSpinner } from '../__generated__/components/LoadingSpinner'; interface {{ entity.name | pascal_case }}ArticleProps { @@ -13,7 +25,7 @@ interface {{ entity.name | pascal_case }}ArticleProps { export function {{ entity.name | pascal_case }}Article({ basePath = '{{ view.path if view else "/" }}', - publicView = false + publicView = {{ props.public.value | tojson }} }: {{ entity.name | pascal_case }}ArticleProps) { const { id } = useParams<{ id: string }>(); const [record, setRecord] = useState(null); @@ -28,7 +40,7 @@ export function {{ entity.name | pascal_case }}Article({ try { const headers: Record = {}; if (token && !publicView) headers['Authorization'] = `Bearer ${token}`; - const res = await fetch(`${API_BASE_URL}/{{ entity.repository_name | kebab_case }}/${id}`, { headers }); + const res = await callOperation('{{ entity.name }}', 'get', { id, headers }); if (!res.ok) throw new Error(res.status === 404 ? 'Record not found' : `Error: ${res.status}`); setRecord(await res.json()); } catch (err: any) { @@ -40,13 +52,6 @@ export function {{ entity.name | pascal_case }}Article({ fetchRecord(); }, [id, token, publicView]); - {% set roles = props.field_roles.value if props.field_roles is defined else {} %} - {% set title_field = entity.fields[roles.title] if roles.title is defined else none %} - {% set content_field = entity.fields[roles.content] if roles.content is defined else none %} - {% set image_field = entity.fields[roles.image] if roles.image is defined else none %} - {% set status_field = entity.fields[roles.status] if roles.status is defined else none %} - {% set author_field = entity.fields[roles.author] if roles.author is defined else none %} - {% set date_field = entity.fields[roles.date] if roles.date is defined else none %} if (loading) return ; @@ -105,13 +110,6 @@ export function {{ entity.name | pascal_case }}Article({ {% if author_field.name == 'authorId' %}Author{% else %}{{ author_field.name | title }}{% endif %} - {!publicView && ( -
- -
- )} {% endif %} @@ -140,7 +138,7 @@ export function {{ entity.name | pascal_case }}Article({ {% if field.name not in ([entity.pk_field] + (roles.values() | list)) %}
{{ field.name | title }}
-
{String(record.{{ field.name }} ?? '—')}
+
{displayValue(record.{{ field.name }})}
{% endif %} {% endfor %} @@ -149,13 +147,23 @@ export function {{ entity.name | pascal_case }}Article({ {/* Footer */} -
+
+ {!publicView && ( + <> + {% call allowed(entity, 'edit') %} + + {% endcall %} + {{ delete_record(entity, pages) }} + + )}
diff --git a/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/detail-drawer.jinja b/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/detail-drawer.jinja index 92594460..1e2d1768 100644 --- a/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/detail-drawer.jinja +++ b/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/detail-drawer.jinja @@ -1,29 +1,48 @@ -import { useNavigate } from 'react-router-dom'; +{% from 'macros/access.jinja' import allowed %} +{% from 'macros/delete.jinja' import delete_page, delete_record %} +{% set roles = props.field_roles.value if props.field_roles is defined else {} %} +{% set title_field = entity.fields[roles.title] if roles.title is defined else none %} +{% set image_field = entity.fields[roles.image] if roles.image is defined else none %} +{% set content_field = entity.fields[roles.content] if roles.content is defined else none %} +{% set meta_fields = entity.fields.values() | rejectattr('name', 'in', ([entity.pk_field] + (roles.values() | list)) | select | list) | list %} +import { useEffect, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; +{{ delete_page(pages) }} +import { Allowed, useAuth } from '../__generated__/App'; import { Button } from '../__generated__/components/Button'; +{% if meta_fields | selectattr('type', 'equalto', 'enum') | list %} import { Badge } from '../__generated__/components/Badge'; +{% endif %} +import { callOperation, displayValue, readJson } from '../__generated__/records'; interface {{ entity.name | pascal_case }}DrawerProps { - record: any; + record?: any; basePath?: string; onClose?: () => void; onEdit?: () => void; - onDelete?: () => void; } export function {{ entity.name | pascal_case }}Drawer({ record, basePath = '{{ view.path if view else "/" }}', onClose, - onEdit, - onDelete + onEdit }: {{ entity.name | pascal_case }}DrawerProps) { const navigate = useNavigate(); + const { id } = useParams<{ id: string }>(); + const { token } = useAuth(); + const [loaded, setLoaded] = useState(null); + + useEffect(() => { + if (record || !id) return; + const headers: Record = {}; + if (token) headers['Authorization'] = `Bearer ${token}`; + callOperation('{{ entity.name }}', 'get', { id, headers }).then(readJson).then(setLoaded).catch(() => setLoaded(null)); + }, [record, id, token]); + + const shown = record ?? loaded; + if (!shown) return null; - {% set roles = props.field_roles.value if props.field_roles is defined else {} %} - {% set title_field = entity.fields[roles.title] if roles.title is defined else none %} - {% set image_field = entity.fields[roles.image] if roles.image is defined else none %} - {% set content_field = entity.fields[roles.content] if roles.content is defined else none %} - {% set meta_fields = entity.fields.values() | rejectattr('name', 'in', ([entity.pk_field] + (roles.values() | list)) | select | list) | list %} return ( <> @@ -42,11 +61,11 @@ export function {{ entity.name | pascal_case }}Drawer({
{% if title_field %} -

{record.{{ title_field.name }}}

+

{shown.{{ title_field.name }}}

{% else %}

{{ entity.name | title }}

{% endif %} -

{record.{{ entity.pk_field }}}

+

{shown.{{ entity.pk_field }}}

- + {% endcall %} + {{ delete_record(entity, pages) }}
+ {% call allowed(entity, 'edit') %} + {% endcall %} + {{ delete_record(entity, pages) }} {/* Schema-aware field grid */} @@ -133,9 +141,9 @@ export function {{ entity.name | pascal_case }}Schema({ {% elif field.type == 'boolean' %} {String(record.{{ field.name }})} {% elif field.type == 'number' %} - {String(record.{{ field.name }} ?? '—')} + {displayValue(record.{{ field.name }})} {% else %} - {String(record.{{ field.name }} ?? '—')} + {displayValue(record.{{ field.name }})} {% endif %} diff --git a/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/detail-tabbed.jinja b/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/detail-tabbed.jinja index 817cde9d..c376f6b5 100644 --- a/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/detail-tabbed.jinja +++ b/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/detail-tabbed.jinja @@ -1,9 +1,17 @@ +{% from 'macros/access.jinja' import allowed %} +{% from 'macros/delete.jinja' import delete_page, delete_record %} +{% set roles = props.field_roles.value if props.field_roles is defined else {} %} +{% set title_field = entity.fields[roles.title] if roles.title is defined else none %} +{% set status_field = entity.fields[roles.status] if roles.status is defined else none %} import { useEffect, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; -import { useAuth } from '../__generated__/App'; -import { API_BASE_URL } from '../__generated__/config'; +{{ delete_page(pages) }} +import { Allowed, useAuth } from '../__generated__/App'; +import { callOperation, displayValue } from '../__generated__/records'; import { Button } from '../__generated__/components/Button'; +{% if status_field or entity.fields.values() | selectattr('type', 'equalto', 'enum') | first %} import { Badge } from '../__generated__/components/Badge'; +{% endif %} import { LoadingSpinner } from '../__generated__/components/LoadingSpinner'; type Tab = 'fields' | 'relations' | 'activity' | 'json'; @@ -19,7 +27,7 @@ export function {{ entity.name | pascal_case }}Tabbed({ const [record, setRecord] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [activeTab, setActiveTab] = useState('fields'); + const [activeTab, setActiveTab] = useState({{ props.tabs.value[0] | tojson }}); const navigate = useNavigate(); const { token } = useAuth(); @@ -29,7 +37,7 @@ export function {{ entity.name | pascal_case }}Tabbed({ try { const headers: Record = {}; if (token) headers['Authorization'] = `Bearer ${token}`; - const res = await fetch(`${API_BASE_URL}/{{ entity.repository_name | kebab_case }}/${id}`, { headers }); + const res = await callOperation('{{ entity.name }}', 'get', { id, headers }); if (!res.ok) throw new Error(res.status === 404 ? 'Record not found' : `Error: ${res.status}`); setRecord(await res.json()); } catch (err: any) { @@ -41,9 +49,6 @@ export function {{ entity.name | pascal_case }}Tabbed({ fetchRecord(); }, [id, token]); - {% set roles = props.field_roles.value if props.field_roles is defined else {} %} - {% set title_field = entity.fields[roles.title] if roles.title is defined else none %} - {% set status_field = entity.fields[roles.status] if roles.status is defined else none %} {% set relation_fields = entity.fields.values() | selectattr('type', 'equalto', 'relation') | list %} {% set non_relation_fields = entity.fields.values() | rejectattr('type', 'equalto', 'relation') | rejectattr('name', 'equalto', entity.pk_field) | list %} @@ -61,12 +66,12 @@ export function {{ entity.name | pascal_case }}Tabbed({ if (!record) return null; - const tabs: { id: Tab; label: string }[] = [ + const tabs = ([ { id: 'fields', label: 'Fields' }, { id: 'relations', label: 'Relations ({{ relation_fields | length }})' }, { id: 'activity', label: 'Activity' }, { id: 'json', label: 'JSON' }, - ]; + ] as { id: Tab; label: string }[]).filter(({ id }) => ({{ props.tabs.value | tojson }} as Tab[]).includes(id)); return (
@@ -88,9 +93,12 @@ export function {{ entity.name | pascal_case }}Tabbed({ {% if status_field %} {% endif %} + {% call allowed(entity, 'edit') %} + {% endcall %} + {{ delete_record(entity, pages) }}
@@ -133,7 +141,7 @@ export function {{ entity.name | pascal_case }}Tabbed({ {% else %} - {String(record.{{ field.name }} ?? '—')} + {displayValue(record.{{ field.name }})} {% endif %} @@ -151,7 +159,7 @@ export function {{ entity.name | pascal_case }}Tabbed({
{{ field.name | title }} {{ field.type }} - {String(record.{{ field.name }} ?? '—')} + {displayValue(record.{{ field.name }})}
{% endfor %} diff --git a/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/detail-two-column.jinja b/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/detail-two-column.jinja index 82ff8238..1024a476 100644 --- a/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/detail-two-column.jinja +++ b/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/detail-two-column.jinja @@ -1,7 +1,10 @@ +{% from 'macros/access.jinja' import allowed %} +{% from 'macros/delete.jinja' import delete_page, delete_record %} import { useEffect, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; -import { useAuth } from '../__generated__/App'; -import { API_BASE_URL } from '../__generated__/config'; +{{ delete_page(pages) }} +import { Allowed, useAuth } from '../__generated__/App'; +import { callOperation, displayValue } from '../__generated__/records'; import { Button } from '../__generated__/components/Button'; {% set has_enum = entity.fields.values() | selectattr('type', 'equalto', 'enum') | list | length > 0 %} {% if has_enum %} @@ -11,12 +14,10 @@ import { LoadingSpinner } from '../__generated__/components/LoadingSpinner'; interface {{ entity.name | pascal_case }}TwoColumnProps { basePath?: string; - onDelete?: (id: string) => void; } export function {{ entity.name | pascal_case }}TwoColumn({ - basePath = '{{ view.path if view else "/" }}', - onDelete + basePath = '{{ view.path if view else "/" }}' }: {{ entity.name | pascal_case }}TwoColumnProps) { const { id } = useParams<{ id: string }>(); const [record, setRecord] = useState(null); @@ -31,7 +32,7 @@ export function {{ entity.name | pascal_case }}TwoColumn({ try { const headers: Record = {}; if (token) headers['Authorization'] = `Bearer ${token}`; - const res = await fetch(`${API_BASE_URL}/{{ entity.repository_name | kebab_case }}/${id}`, { headers }); + const res = await callOperation('{{ entity.name }}', 'get', { id, headers }); if (!res.ok) throw new Error(res.status === 404 ? 'Record not found' : `Error: ${res.status}`); setRecord(await res.json()); } catch (err: any) { @@ -84,20 +85,12 @@ export function {{ entity.name | pascal_case }}TwoColumn({ {% if status_field %} {% endif %} - {% if 'update' in entity.operations %} + {% call allowed(entity, 'edit') %} - {% endif %} - {% if 'delete' in entity.operations %} - - {% endif %} + {% endcall %} + {{ delete_record(entity, pages) }} {/* Two-column layout: 1fr main + 280px metadata rail */} @@ -146,7 +139,7 @@ export function {{ entity.name | pascal_case }}TwoColumn({ {% elif field.type in ['datetime', 'date'] or field.name in ['createdAt', 'updatedAt', 'publishedAt', 'date'] %} {record.{{ field.name }} ? new Date(record.{{ field.name }}).toLocaleString() : '—'} {% else %} - {String(record.{{ field.name }} ?? '—')} + {displayValue(record.{{ field.name }})} {% endif %} @@ -162,7 +155,7 @@ export function {{ entity.name | pascal_case }}TwoColumn({ {% for field in relation_fields %}
{{ field.name | title }}
-
{String(record.{{ field.name }} ?? '—')}
+
{displayValue(record.{{ field.name }})}
{% endfor %} diff --git a/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/empty-centered.jinja b/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/empty-centered.jinja index 644f7cfc..b3e155fe 100644 --- a/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/empty-centered.jinja +++ b/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/empty-centered.jinja @@ -7,9 +7,9 @@ interface EmptyCenteredProps { } export function EmptyCentered({ - title = 'No {{ entity.name | lower | plural }} yet', - subtitle = 'Create your first record to get started.', - actionLabel = '+ New {{ entity.name | title }}', + title = {{ (props.title.value if props.title else 'No ' ~ entity.repository_name ~ ' yet') | tojson }}, + subtitle = {{ props.message.value | tojson }}, + actionLabel = {{ (props.action_label.value if props.action_label else '+ New ' ~ entity.name | title) | tojson }}, onAction, icon = '∅', }: EmptyCenteredProps) { @@ -30,3 +30,5 @@ export function EmptyCentered({ ); } + +export default EmptyCentered; diff --git a/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/empty-filtered.jinja b/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/empty-filtered.jinja index 0cabd162..b748ada0 100644 --- a/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/empty-filtered.jinja +++ b/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/empty-filtered.jinja @@ -4,15 +4,15 @@ interface ActiveFilter { } interface EmptyFilteredProps { - activeFilters: ActiveFilter[]; - onRemoveFilter: (key: string) => void; - onClearAll: () => void; + activeFilters?: ActiveFilter[]; + onRemoveFilter?: (key: string) => void; + onClearAll?: () => void; onNewRecord?: () => void; entityLabel?: string; } export function EmptyFiltered({ - activeFilters, + activeFilters = {{ props.filters.value | tojson }}.map((label) => ({ key: label, label })), onRemoveFilter, onClearAll, onNewRecord, @@ -31,7 +31,7 @@ export function EmptyFiltered({ > {f.label} - {/* Import CSV */} + {% endif %} + {% if 'import' in props.options.value %}
↑ @@ -49,7 +45,8 @@ export function EmptyImport({
- {/* Sample data */} + {% endif %} + {% if 'sample' in props.options.value %}
≡ @@ -62,8 +59,11 @@ export function EmptyImport({ Load
+ {% endif %}
); } + +export default EmptyImport; diff --git a/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/empty-inline.jinja b/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/empty-inline.jinja index 75ac880d..b05f85cc 100644 --- a/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/empty-inline.jinja +++ b/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/empty-inline.jinja @@ -1,19 +1,19 @@ import { useState } from 'react'; interface EmptyInlineProps { - columns: string[]; - onCommit: (row: Record) => void; + columns?: string[]; + onCommit?: (row: Record) => void; placeholder?: Record; } -export function EmptyInline({ columns, onCommit, placeholder = {} }: EmptyInlineProps) { +export function EmptyInline({ columns = {{ collection.fields | list | tojson }}, onCommit, placeholder = {} }: EmptyInlineProps) { const [draft, setDraft] = useState>({}); - const handleKeyDown = (e: React.KeyboardEvent, col: string) => { + const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault(); if (Object.values(draft).some(Boolean)) { - onCommit(draft); + onCommit?.(draft); setDraft({}); } } @@ -40,7 +40,7 @@ export function EmptyInline({ columns, onCommit, placeholder = {} }: EmptyInline value={draft[col] ?? ''} placeholder={placeholder[col] ?? col} onChange={(e) => setDraft({ ...draft, [col]: e.target.value })} - onKeyDown={(e) => handleKeyDown(e, col)} + onKeyDown={handleKeyDown} className="w-full px-3 py-2 text-sm bg-transparent outline-none border-[1.5px] border-transparent focus:border-wf-accent rounded-sm text-wf-ink placeholder:text-wf-ink-faint" /> @@ -67,3 +67,5 @@ export function EmptyInline({ columns, onCommit, placeholder = {} }: EmptyInline ); } + +export default EmptyInline; diff --git a/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/empty-onboarding.jinja b/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/empty-onboarding.jinja index 49f9e784..58080e08 100644 --- a/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/empty-onboarding.jinja +++ b/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/empty-onboarding.jinja @@ -7,15 +7,15 @@ interface OnboardingStep { } interface EmptyOnboardingProps { - title: string; + title?: string; subtitle?: string; - steps: OnboardingStep[]; + steps?: OnboardingStep[]; } export function EmptyOnboarding({ - title, - subtitle = 'A few steps and you\'re running.', - steps, + title = {{ props.title.value | tojson }}, + subtitle = {{ props.subtitle.value | tojson }}, + steps = {{ props.steps.value | tojson }}, }: EmptyOnboardingProps) { return (
@@ -70,3 +70,5 @@ export function EmptyOnboarding({
); } + +export default EmptyOnboarding; diff --git a/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/filter-builder.jinja b/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/filter-builder.jinja index 74886cfc..7e67aa21 100644 --- a/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/filter-builder.jinja +++ b/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/filter-builder.jinja @@ -1,7 +1,8 @@ +{% from 'macros/access.jinja' import allowed %} import { useEffect, useState, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; -import { useAuth } from '../__generated__/App'; -import { API_BASE_URL } from '../__generated__/config'; +import { Allowed, useAuth } from '../__generated__/App'; +import { displayValue, listRecords } from '../__generated__/records'; interface {{ entity.name | pascal_case }} { {{ entity.pk_field }}: string; @@ -23,7 +24,7 @@ interface FilterRule { const FIELDS = [ {% for field in entity.fields.values() %} - {% if field.name != entity.pk_field %} + {% if field.name != entity.pk_field and (not props.fields or field.name in props.fields.value) %} { name: '{{ field.name }}', label: '{{ field.name | title }}', type: '{{ field.type }}' }, {% endif %} {% endfor %} @@ -76,12 +77,8 @@ export default function {{ view.name | pascal_case }}() { const headers: Record = {}; if (token) headers['Authorization'] = `Bearer ${token}`; - fetch(`${API_BASE_URL}/{{ entity.repository_name | kebab_case }}`, { headers }) - .then(res => { - if (!res.ok) throw new Error(res.status === 401 ? 'Authentication required' : `Error: ${res.status}`); - return res.json(); - }) - .then(data => { setItems(Array.isArray(data) ? data : []); setLoading(false); }) + listRecords('{{ entity.name }}', headers) + .then(data => { setItems(data); setLoading(false); }) .catch(err => { setError(err.message); setLoading(false); }); }, [token]); @@ -269,12 +266,14 @@ export default function {{ view.name | pascal_case }}() { )} + {% call allowed(entity, 'create') %} + {% endcall %} {/* Table */} @@ -312,9 +311,9 @@ export default function {{ view.name | pascal_case }}() { {% if field.name != entity.pk_field and ns.count < 5 %} {% if field.type == 'enum' %} - {String(item.{{ field.name }} ?? '—')} + {displayValue(item.{{ field.name }})} {% else %} - {String(item.{{ field.name }} ?? '—')} + {displayValue(item.{{ field.name }})} {% endif %} {% set ns.count = ns.count + 1 %} diff --git a/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/filter-column.jinja b/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/filter-column.jinja index 61dcc6ba..80d14d97 100644 --- a/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/filter-column.jinja +++ b/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/filter-column.jinja @@ -1,7 +1,8 @@ +{% from 'macros/access.jinja' import allowed %} import { useEffect, useState, useRef, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; -import { useAuth } from '../__generated__/App'; -import { API_BASE_URL } from '../__generated__/config'; +import { Allowed, useAuth } from '../__generated__/App'; +import { displayValue, listRecords } from '../__generated__/records'; interface {{ entity.name | pascal_case }} { {{ entity.pk_field }}: string; @@ -56,12 +57,8 @@ export default function {{ view.name | pascal_case }}() { const headers: Record = {}; if (token) headers['Authorization'] = `Bearer ${token}`; - fetch(`${API_BASE_URL}/{{ entity.repository_name | kebab_case }}`, { headers }) - .then(res => { - if (!res.ok) throw new Error(res.status === 401 ? 'Authentication required' : `Error: ${res.status}`); - return res.json(); - }) - .then(data => { setItems(Array.isArray(data) ? data : []); setLoading(false); }) + listRecords('{{ entity.name }}', headers) + .then(data => { setItems(data); setLoading(false); }) .catch(err => { setError(err.message); setLoading(false); }); }, [token]); @@ -166,12 +163,14 @@ export default function {{ view.name | pascal_case }}() {

{{ entity.name | title }}

{filtered.length} of {items.length}
+ {% call allowed(entity, 'create') %} + {% endcall %}
{/* Table with column-header filters */} @@ -274,7 +273,7 @@ export default function {{ view.name | pascal_case }}() { {col.key === '{{ entity.pk_field }}' ? ( {String((item as any)[col.key]).slice(0, 8)}… ) : ( - String((item as any)[col.key] ?? '—') + displayValue((item as any)[col.key]) )} ))} diff --git a/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/filter-facets.jinja b/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/filter-facets.jinja index a97d8e9a..6c8b0abd 100644 --- a/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/filter-facets.jinja +++ b/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/filter-facets.jinja @@ -1,7 +1,8 @@ +{% from 'macros/access.jinja' import allowed %} import { useEffect, useState, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; -import { useAuth } from '../__generated__/App'; -import { API_BASE_URL } from '../__generated__/config'; +import { Allowed, useAuth } from '../__generated__/App'; +import { displayValue, listRecords } from '../__generated__/records'; interface {{ entity.name | pascal_case }} { {{ entity.pk_field }}: string; @@ -10,7 +11,7 @@ interface {{ entity.name | pascal_case }} { {% endfor %} } -{% set facet_fields = entity.fields.values() | selectattr('type', 'in', ['enum', 'boolean']) | list %} +{% set facet_fields = entity.fields.values() | selectattr('type', 'in', ['enum', 'boolean']) | selectattr('name', 'in', props.fields.value if props.fields else entity.fields) | list %} {% set roles = props.field_roles.value if props.field_roles is defined else {} %} {% set title_field = entity.fields[roles.title] if roles.title is defined else none %} {% set date_field = entity.fields[roles.date] if roles.date is defined else none %} @@ -35,12 +36,8 @@ export default function {{ view.name | pascal_case }}() { const headers: Record = {}; if (token) headers['Authorization'] = `Bearer ${token}`; - fetch(`${API_BASE_URL}/{{ entity.repository_name | kebab_case }}`, { headers }) - .then(res => { - if (!res.ok) throw new Error(res.status === 401 ? 'Authentication required' : `Error: ${res.status}`); - return res.json(); - }) - .then(data => { setItems(Array.isArray(data) ? data : []); setLoading(false); }) + listRecords('{{ entity.name }}', headers) + .then(data => { setItems(data); setLoading(false); }) .catch(err => { setError(err.message); setLoading(false); }); }, [token]); @@ -164,12 +161,14 @@ export default function {{ view.name | pascal_case }}() {

{{ entity.name | title }}

{filtered.length} of {items.length}
+ {% call allowed(entity, 'create') %} + {% endcall %}
{/* Table */} @@ -205,12 +204,12 @@ export default function {{ view.name | pascal_case }}() { className="cursor-pointer" > {% if title_field %} - {String(item.{{ title_field.name }} ?? '—')} + {displayValue(item.{{ title_field.name }})} {% else %} {String(item.{{ entity.pk_field }}).slice(0, 8)}… {% endif %} {% for field in facet_fields %} - {String(item.{{ field.name }} ?? '—')} + {displayValue(item.{{ field.name }})} {% endfor %} {% if date_field %} diff --git a/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/filter-panel.jinja b/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/filter-panel.jinja index f0f6b7d8..2aa1f4ba 100644 --- a/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/filter-panel.jinja +++ b/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/filter-panel.jinja @@ -80,3 +80,5 @@ export function FilterPanel({ fields, activeFilters, onFilterChange }: FilterPan ); } + +export default FilterPanel; diff --git a/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/filter-toolbar.jinja b/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/filter-toolbar.jinja index 69b2b90d..34c6fe72 100644 --- a/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/filter-toolbar.jinja +++ b/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/filter-toolbar.jinja @@ -1,7 +1,8 @@ +{% from 'macros/access.jinja' import allowed %} import { useEffect, useState, useRef } from 'react'; import { useNavigate } from 'react-router-dom'; -import { useAuth } from '../__generated__/App'; -import { API_BASE_URL } from '../__generated__/config'; +import { Allowed, useAuth } from '../__generated__/App'; +import { displayValue, listRecords } from '../__generated__/records'; interface {{ entity.name | pascal_case }} { {{ entity.pk_field }}: string; @@ -19,9 +20,9 @@ interface ActiveFilter { {% set roles = props.field_roles.value if props.field_roles is defined else {} %} {% set title_field = entity.fields[roles.title] if roles.title is defined else none %} -const FILTERABLE_FIELDS = [ +const FILTERABLE_FIELDS: { name: string; label: string }[] = [ {% for field in entity.fields.values() %} - {% if field.name != entity.pk_field %} + {% if field.name != entity.pk_field and (not props.fields or field.name in props.fields.value) %} { name: '{{ field.name }}', label: '{{ field.name | title }}' }, {% endif %} {% endfor %} @@ -32,7 +33,9 @@ export default function {{ view.name | pascal_case }}() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [search, setSearch] = useState(''); - const [activeFilters, setActiveFilters] = useState([]); + const [activeFilters, setActiveFilters] = useState( + Object.entries({{ props.active_filters.value | tojson }}).map(([field, value]) => ({ field, value: String(value) })), + ); const [sortField, setSortField] = useState('{{ sortable_fields[0].name if sortable_fields else entity.pk_field }}'); const [sortAsc, setSortAsc] = useState(true); const [filterOpen, setFilterOpen] = useState(false); @@ -46,12 +49,8 @@ export default function {{ view.name | pascal_case }}() { const headers: Record = {}; if (token) headers['Authorization'] = `Bearer ${token}`; - fetch(`${API_BASE_URL}/{{ entity.repository_name | kebab_case }}`, { headers }) - .then(res => { - if (!res.ok) throw new Error(res.status === 401 ? 'Authentication required' : `Error: ${res.status}`); - return res.json(); - }) - .then(data => { setItems(Array.isArray(data) ? data : []); setLoading(false); }) + listRecords('{{ entity.name }}', headers) + .then(data => { setItems(data); setLoading(false); }) .catch(err => { setError(err.message); setLoading(false); }); }, [token]); @@ -175,12 +174,14 @@ export default function {{ view.name | pascal_case }}() { Sort: {sortField} {sortAsc ? '↑' : '↓'} + {% call allowed(entity, 'create') %} + {% endcall %} {/* Active filter chips */} @@ -251,9 +252,9 @@ export default function {{ view.name | pascal_case }}() { {% if field.name != entity.pk_field and ns.count < 5 %} {% if field.type == 'enum' %} - {String(item.{{ field.name }} ?? '—')} + {displayValue(item.{{ field.name }})} {% else %} - {String(item.{{ field.name }} ?? '—')} + {displayValue(item.{{ field.name }})} {% endif %} {% set ns.count = ns.count + 1 %} diff --git a/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/form-inline-row.jinja b/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/form-inline-row.jinja index 8a8393e8..6157548a 100644 --- a/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/form-inline-row.jinja +++ b/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/form-inline-row.jinja @@ -1,58 +1,20 @@ -{% macro inline_field(field, formData='editData', handler='handleFieldChange') %} -{% set is_enum = field.type == 'enum' %} -{% set is_boolean = field.type == 'boolean' %} -{% set input_type = 'number' if field.type == 'number' else ('datetime-local' if field.type == 'datetime' else 'text') %} -
- - {% if is_boolean %} - - {% elif is_enum and field.enum_values %} -
- -
- {% else %} -
- -
- {% endif %} -
-{% endmacro %} +{% from 'macros/access.jinja' import allowed %} +{% from 'macros/field_input.jinja' import field_input, control_imports %} +{% set roles = props.field_roles.value if props.field_roles is defined else {} %} +{% set role_names = roles.values() | list %} +{% set role_fields = [] %} +{% for name in role_names %} +{% if name in entity.fields %}{% if role_fields.append(entity.fields[name]) %}{% endif %}{% endif %} +{% endfor %} +{% set other_fields = entity.fields.values() | rejectattr('name', 'equalto', entity.pk_field) | rejectattr('readonly') | rejectattr('name', 'in', role_names) | list %} +{% set display_fields = (role_fields + other_fields)[:4] %} +{% set edit_fields = display_fields | rejectattr('name', 'in', ['content', 'body', 'description', 'excerpt', 'bio']) | list %} import { useEffect, useState } from 'react'; import { Link, useNavigate } from 'react-router-dom'; -import { useAuth } from '../__generated__/App'; -import { API_BASE_URL } from '../__generated__/config'; +import { Allowed, useAuth } from '../__generated__/App'; +import { displayValue, listRecords } from '../__generated__/records'; +import { useRecordForm } from '../__generated__/useRecordForm'; +{{ control_imports(edit_fields) }} interface {{ entity.name | pascal_case }} { {{ entity.pk_field }}: string; @@ -66,22 +28,18 @@ export default function {{ view.name | pascal_case }}() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [editingId, setEditingId] = useState(null); - const [editData, setEditData] = useState>({}); const [saving, setSaving] = useState(false); const { token } = useAuth(); const navigate = useNavigate(); + const { values, setValue, submit } = useRecordForm<{{ entity.name | pascal_case }}>({{ entity.name | tojson }}, editingId ?? undefined); useEffect(() => { const headers: Record = {}; if (token) headers['Authorization'] = `Bearer ${token}`; - fetch(`${API_BASE_URL}/{{ entity.repository_name | kebab_case }}`, { headers }) - .then(res => { - if (!res.ok) throw new Error(res.status === 401 ? 'Authentication required' : `Error: ${res.status}`); - return res.json(); - }) + listRecords('{{ entity.name }}', headers) .then(data => { - setItems(Array.isArray(data) ? data : []); + setItems(data); setLoading(false); }) .catch(err => { @@ -90,19 +48,9 @@ export default function {{ view.name | pascal_case }}() { }); }, [token]); - const startEdit = (item: {{ entity.name | pascal_case }}) => { - setEditingId(item.{{ entity.pk_field }}); - setEditData({ ...item }); - }; + const startEdit = (item: {{ entity.name | pascal_case }}) => setEditingId(item.{{ entity.pk_field }}); - const cancelEdit = () => { - setEditingId(null); - setEditData({}); - }; - - const handleFieldChange = ( - e: React.ChangeEvent - ) => setEditData(prev => ({ ...prev, [e.target.name]: e.target.value })); + const cancelEdit = () => setEditingId(null); const handleSave = async (e: React.FormEvent) => { e.preventDefault(); @@ -110,21 +58,7 @@ export default function {{ view.name | pascal_case }}() { setSaving(true); try { - const headers: Record = { 'Content-Type': 'application/json' }; - if (token) headers['Authorization'] = `Bearer ${token}`; - - const res = await fetch(`${API_BASE_URL}/{{ entity.repository_name | kebab_case }}/${editingId}`, { - method: 'PUT', - headers, - body: JSON.stringify(editData), - }); - - if (!res.ok) { - const data = await res.json().catch(() => ({})); - throw new Error(data.detail || 'Failed to save'); - } - - const updated = await res.json(); + const updated = await submit(); setItems(prev => prev.map(i => i.{{ entity.pk_field }} === editingId ? updated : i)); cancelEdit(); } catch (err: unknown) { @@ -153,15 +87,6 @@ export default function {{ view.name | pascal_case }}() { ); } - {% set roles = props.field_roles.value if props.field_roles is defined else {} %} - {% set role_names = roles.values() | list %} - {% set role_fields = [] %} - {% for name in role_names %} - {% if name in entity.fields %}{% if role_fields.append(entity.fields[name]) %}{% endif %}{% endif %} - {% endfor %} - {% set other_fields = entity.fields.values() | rejectattr('name', 'equalto', entity.pk_field) | rejectattr('readonly') | rejectattr('name', 'in', role_names) | list %} - {% set display_fields = (role_fields + other_fields)[:4] %} - {% set edit_fields = display_fields | rejectattr('name', 'in', ['content', 'body', 'description', 'excerpt', 'bio']) | list %} {% set col_count = [edit_fields | length, 4] | min %} return ( @@ -171,12 +96,14 @@ export default function {{ view.name | pascal_case }}() {

{{ entity.name | title }}

{items.length} records
+ {% call allowed(entity, 'create') %} + {% endcall %}
{/* Table with inline-edit rows */} @@ -212,7 +139,7 @@ export default function {{ view.name | pascal_case }}() { > {% for field in edit_fields %} {% if loop.index0 < 4 %} - {{ inline_field(field) }} + {{ field_input(field, id_prefix='inline-') }} {% endif %} {% endfor %} @@ -236,9 +163,9 @@ export default function {{ view.name | pascal_case }}() { {% for field in display_fields %} {% if field.type == 'enum' %} - {String(item.{{ field.name }} || '—')} + {displayValue(item.{{ field.name }})} {% else %} - {String(item.{{ field.name }} || '—')} + {displayValue(item.{{ field.name }})} {% endif %} {% endfor %} @@ -271,9 +198,11 @@ export default function {{ view.name | pascal_case }}() {

No {{ entity.name | lower }}s yet

Create your first record to get started.

+ {% call allowed(entity, 'create') %} + {% endcall %}
)} diff --git a/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/form-modal.jinja b/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/form-modal.jinja index e68c583b..969267a0 100644 --- a/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/form-modal.jinja +++ b/src/yaml_api_generator/generators/frontend/packs/builtin/generic/templates/components/form-modal.jinja @@ -1,88 +1,23 @@ -{% macro field_input(field, formData='formData', handler='handleFieldChange') %} -{% set is_textarea = field.name in ['content', 'body', 'description', 'excerpt', 'bio'] %} -{% set is_enum = field.type == 'enum' %} -{% set is_boolean = field.type == 'boolean' %} -{% set input_type = 'number' if field.type == 'number' else ('datetime-local' if field.type == 'datetime' else 'text') %} -
- - {% if is_boolean %} - - {% elif is_enum and field.enum_values %} -
- -
- {% elif is_textarea %} -
-