diff --git a/scripts/sync-manifest.mjs b/scripts/sync-manifest.mjs index f6004d7..892112c 100644 --- a/scripts/sync-manifest.mjs +++ b/scripts/sync-manifest.mjs @@ -142,6 +142,8 @@ export const PAGES = [ { product: 'cascade', group: 'Use it', slug: 'cascade/importing', repo: 'cascade', file: 'README.md', sections: ['Bringing a workflow in'], title: 'Bringing a workflow in', description: 'Import an existing workflow into Cascade, what maps across and what the importer tells you it could not carry.' }, { product: 'cascade', group: 'Use it', slug: 'cascade/observability', repo: 'cascade', file: 'README.md', sections: ['Watching it'], title: 'Watching it', description: 'Health, readiness, Prometheus metrics and traces for every run and step, so a stuck job shows up on a graph and not in a complaint.' }, { product: 'cascade', group: 'Use it', slug: 'cascade/file-steps', repo: 'cascade', file: 'README.md', sections: ['The file steps'], title: 'The file steps', description: 'The steps that read, write, list and move files, build CSV or Excel, and read a PDF, all fenced to one directory and off until you set it.' }, + { product: 'cascade', group: 'Identity', slug: 'cascade/identity', repo: 'cascade', file: 'docs/IDENTITY.md', title: 'Identity and access', description: 'Custom roles, sign-in with LDAP, Active Directory and SAML, and SCIM provisioning, all in every Cascade with no paid tier.' }, + { product: 'cascade', group: 'Governance', slug: 'cascade/governance', repo: 'cascade', file: 'docs/GOVERNANCE.md', title: 'Governing a Cascade instance', description: 'Variables, folders, annotated runs, redaction, insights, outside secrets and log streaming, for a plant with a growing pile of workflows.' }, { product: 'cascade', group: 'Install', slug: 'cascade/quadlet', repo: 'cascade', file: 'deploy/quadlet/README.md', title: 'Cascade with Podman Quadlet', description: 'Run Cascade as systemd services with Podman Quadlet, on SQLite or with Postgres and a separate worker, with an update window you control.' }, { product: 'cascade', group: 'Install', slug: 'cascade/building', repo: 'cascade', file: 'README.md', sections: ['Building'], title: 'Building Cascade', description: 'Build the Cascade binary and image from source, and run the vet and race test suite against real services.' }, { product: 'cascade', group: 'Project', slug: 'cascade/roadmap', repo: 'cascade', file: 'docs/ROADMAP.md', title: 'Cascade roadmap', description: 'What Cascade has built and what comes after the first release, phase by phase, and what each phase has to prove first.' }, diff --git a/src/content/docs/cascade/governance.md b/src/content/docs/cascade/governance.md new file mode 100644 index 0000000..7368dae --- /dev/null +++ b/src/content/docs/cascade/governance.md @@ -0,0 +1,432 @@ +--- +# generated by scripts/sync-docs.mjs +title: "Governing a Cascade instance" +description: "Variables, folders, annotated runs, redaction, insights, outside secrets and log streaming, for a plant with a growing pile of workflows." +editUrl: false +--- + +How a plant keeps a growing pile of workflows under control: shared settings, +folders, annotated runs, redaction, numbers over time, outside secrets, log +streaming and where large data lives. Everything here is free and in every +build. + +Every endpoint named below is in the OpenAPI document at +`/api/v1/openapi.json`, and every one has a test that calls it, on SQLite and on +Postgres. + +## Variables + +In the editor: Variables, in the top bar. Instance wide ones are at the top, and each project's under its name. Only what your role allows is offered. + +A variable is a named value a workflow reads as `vars.NAME`: a setpoint ceiling, +a plant's name, a base URL that differs between the pilot line and the real one. + +```yaml +- name: Check limit + type: core.if + version: 1 + params: + condition: "{{ item.json.temp > toNumber(vars.TEMP_LIMIT) }}" +``` + +- A variable is **instance wide** or belongs to **one project**. Where both have + a name, the project's wins, and only for that project's workflows. +- A value is text, up to 8 KiB. Use `toNumber(vars.X)` when you need a number. +- A name is letters, digits and underscores, starting with a letter or an + underscore, up to 64 long, because it has to read as `vars.NAME`. +- **Variables are not secrets.** Anyone who can read the project reads its + variables, and the value is not scrubbed from run data. A password, a token or + a key goes in the credential vault, or in an external secret store (below). +- A run reads its variables when it starts, and again if it resumes after a wait + or a restart. A change reaches the next run, not one already in the middle of + a step. A name that isn't there reads as nothing, not as an error. + +| Call | What it does | +|---|---| +| `GET /api/v1/variables` | Instance variables and those in projects you can read; `?project=` narrows to one project. | +| `POST /api/v1/variables` | Make one. With `projectId` it's the project's, with none it's instance wide. | +| `PUT /api/v1/variables/{variable}` | Change its name and value. | +| `DELETE /api/v1/variables/{variable}` | Delete it. | + +Who may: in a project, the `variable.read` permission lists them and +`variable.create`, `variable.update` and `variable.delete` change them. Viewers +and operators read, editors and admins change. Instance wide variables are +readable by anyone signed in, because any project's workflow can read them, and +only an instance owner or admin changes them. A custom role can carry any of +those four permissions. Changes are in the audit log as `variable.created`, +`variable.updated` and `variable.deleted`, with the name and never the value. + +## Folders + +In the editor: the workflow list is the folders. Make one with New folder, nest it with Add subfolder, and move a workflow from the list on its own row. Choosing a folder when you make a workflow puts it there. + +Folders group a project's workflows and nest, the way a plant is laid out: area, +line, cell. + +- A folder is in one project. Names are unique among the folders next to each + other, and a name is up to 100 characters. +- Folders nest up to 10 deep. +- A workflow is in one folder or at the top of its project. Saving a new + version doesn't move it. +- A folder with workflows or folders in it can't be deleted (409), so deleting a + folder is never how a workflow goes missing. A folder can't be moved inside + itself or one of its own subfolders, and can't be moved to another project. + +| Call | What it does | +|---|---| +| `GET /api/v1/folders` | Folders in projects you can read, flat, each with its `parentId`; `?project=` narrows. | +| `POST /api/v1/folders` | Make one in `projectId`, inside `parentId` or at the top. | +| `PATCH /api/v1/folders/{folder}` | Rename it, or move it with `parentId` (empty string for the top). | +| `DELETE /api/v1/folders/{folder}` | Delete an empty folder. | +| `PUT /api/v1/workflows/{workflow}/folder` | Put a workflow in a folder, or at the top with an empty `folderId`. | +| `GET /api/v1/workflows?folder=` | One folder's workflows, or `folder=none` for the top of each project. | + +Every workflow the API returns says its `folderId`. The `folder.read`, +`folder.create`, `folder.update` and `folder.delete` permissions work like the +variable ones, and moving a workflow takes `workflow.update`. Folder changes are +in the audit log, and moves as `workflow.moved`. + +## Execution annotations + +In the editor: every run's page has an Annotation box with a thumbs up or down, a note and tags, and the executions list filters by tag and by vote. + +A person can say something about a run after it happened: a thumbs up or down, a +note, and tags to find it by. "First good batch after the die change" and +`scrap` are the kind of thing that goes here. + +```sh +curl -X PUT $CASCADE/api/v1/executions/$RUN/annotation -H "Authorization: Bearer $KEY" \ + -d '{"vote":"up","note":"first good batch after the die change","tags":["die-change","first-shift"]}' +``` + +- A vote is `up`, `down` or empty. A note is up to 2000 characters. A run has up + to 20 tags, each 1 to 40 characters with no control characters, listed once. +- `PUT` replaces the whole annotation, so send everything you want kept. An + annotation that says nothing is the same as none. `DELETE + /api/v1/executions/{execution}/annotation` clears it. +- Every run the API returns carries its annotation, and the listing carries it + on each row. `GET /api/v1/executions?tag=scrap` and `?vote=down` filter, and + they combine with the other filters. +- The annotation records who left it and when, from the same identity the audit + log uses. +- Annotating takes the `execution.annotate` permission, which operators, + editors and admins have and viewers don't. Viewers read annotations. +- An annotation goes when its run is deleted. The audit log keeps + `execution.annotated` and `execution.annotation_cleared` with the vote and + tags, and records only that a note was left, not what it said. + +## Execution redaction policies + +In the editor: Settings, Redaction makes the policies. A workflow picks one in the editor's side panel with nothing selected, under Workflow settings. + +The credential vault already keeps secrets out of everything a run stores. A +redaction policy is for the data that isn't a credential but doesn't belong in +the execution history either: a badge number, an operator's name, a card number +that came in on a webhook. + +A policy is a name and rules: + +- **fields**: item key names. A key a policy names has its whole value replaced + with `[redacted]`, at any depth, whatever type the value is. Matching ignores + case, and `*` and `?` work as wildcards, so `*secret*` takes `plant_secret` + and `SecretKey`. +- **patterns**: regular expressions, in Go's RE2 syntax. Every match inside any + text is replaced with `[redacted]`. A pattern that matches the empty text is + refused. +- **steps**: optional. The policy covers only the steps listed. Empty means + every step. +- **appliesToAll**: optional. The policy covers every workflow, whether or not + it names the policy. + +A workflow chooses one by name in its settings: + +```yaml +settings: + redaction: Badges +``` + +What it does and when: + +- It runs **when the execution finishes**, in the same transaction that marks it + finished: succeeded, failed or stopped. Until then the run has its real data, + because the next step needs it and a restart resumes from it. A run that is + waiting still holds its real data. +- It scrubs each step's stored output, each step's stored error message, the + execution's own error, and a stored webhook answer. It doesn't touch file + contents: a policy reads item data, and a file is a reference to bytes. +- What was stored is what a replay or a retry from the middle of that run will + start from, so a replay sees the scrubbed values. The workflow's trigger items + are in the first step's output, and they are scrubbed too. +- It reads the workflow as that run had it, so editing a workflow, or the + policy, never changes a run that is already over. +- A workflow that names a policy that doesn't exist **fails closed**: its runs + keep no item data at all, the way the `metadata` save setting works, and the + workflow's `problems` say so. A missing policy must never mean data kept that + somebody meant to scrub. +- A policy that workflows name can't be deleted (409), and its name can't change. + Rules can. + +| Call | What it does | +|---|---| +| `GET /api/v1/redaction-policies` | Every policy. Anyone signed in can read them, because the people who write workflows need the names. | +| `POST /api/v1/redaction-policies` | Make one. | +| `PUT /api/v1/redaction-policies/{policy}` | Replace its description and rules. | +| `DELETE /api/v1/redaction-policies/{policy}` | Delete one no workflow names. | + +Making, changing and deleting a policy takes the instance `redaction.manage` +permission, which owners and admins have. Each is in the audit log as +`redaction.created`, `redaction.updated` and `redaction.deleted`. + +## Insights + +In the editor: Insights, in the top bar. Pick a window, a project and a workflow; it shows the totals, a chart and a table per day, and a table per workflow. + +`GET /api/v1/insights` adds the runs in a window up: how they ended, how long +they took, per day and per workflow. + +```sh +curl "$CASCADE/api/v1/insights?from=2026-10-10&to=2026-10-14&project=$PROJECT" -H "Authorization: Bearer $KEY" +``` + +- `from` and `to` are an RFC 3339 time or a date (midnight UTC). With none, it's + the last seven days up to now. The most it answers for is 366 days. +- `project` and `workflow` narrow it. Without them it counts every project the + caller can read executions in, so each person's numbers are their own. +- A run counts on the UTC day it was created. +- `totals` and every row in `days` and `workflows` carry `total`, `succeeded`, + `failed`, `canceled` and `unfinished` (queued, running or waiting), the + average and longest run time in milliseconds over runs that have both a start + and a finish, and `failureRate`. +- `failureRate` is failed over succeeded plus failed. A canceled run isn't a + failure and an unfinished one hasn't decided, so neither is in it. It is 0 + when nothing has finished either way. +- `days` lists every UTC day in the window, with zeros for a day nothing ran, so + a chart has no gaps to guess at. `workflows` is the 50 busiest, busiest first. + +It reads the same execution rows the execution list does, so it needs the +`execution.read` permission and a key scoped for it. + +## External secrets + +In the editor: Settings, External secrets. Add a provider, try it against a path from the same card, and see what the reference for it looks like. + +A credential's secret field can point at a secret kept in HashiCorp Vault or +OpenBao instead of holding the secret itself. Rotate the password in the store +and every workflow uses the new one on its next run, with nobody editing a +credential, and a copy of your database never holds the password. + +A reference looks like this, in any secret field of a credential: + +``` +secret://bao/plant/line-3/db#password +``` + +That is the provider named `bao`, the secret at `plant/line-3/db`, and its +`password` key. The credential stores that text, sealed like any secret. +Nothing it points at is ever stored here: the value is read when a run opens +the credential, held in memory for the provider's cache time, and scrubbed from +everything the run saves, the same as a password in the vault. Only secret +fields take references. A reference typed into a field that isn't secret is just +text. + +### Setting up a provider + +A provider is the server, how Cascade signs in to it, and where it may read. +Making, changing and removing one is an instance owner or admin's, the +`secrets.manage` permission, and has to be done by a person signed in, not by an +API key. + +```sh +curl -X POST $CASCADE/api/v1/secret-providers -H "X-CSRF-Token: ..." -d '{ + "name": "bao", + "address": "https://bao.plant.example:8200", + "authMethod": "approle", "roleId": "...", "secretId": "...", + "allowedPaths": ["plant"] +}' +``` + +| Field | What it does | +|---|---| +| `name` | 1 to 40 lowercase letters, digits and dashes. It is in every reference, so it can't change later. | +| `address` | The server's URL. | +| `mount`, `kvVersion` | Where the KV engine is mounted (`secret` by default) and whether it is version 1 or 2 (2 by default). | +| `namespace` | Sent as `X-Vault-Namespace`, for a namespace of the server. | +| `authMethod` | `token` with `token`, or `approle` with `roleId` and `secretId`. `authMount` is where AppRole is mounted (`approle` by default). AppRole signs in itself and again when its token runs out. | +| `caCert` | A PEM certificate authority to trust for the server, when its certificate isn't from one the system trusts. | +| `allowedPaths` | **Required.** The only places a reference may read from. `plant` allows `plant` and everything under it, and not `plantation`. | +| `projects` | Optional. The only projects that may use the provider. Left empty, every project may. | +| `cacheSeconds` | How long a read secret is kept in memory, 0 to 3600. 30 by default. 0 reads every time. | + +How Cascade signs in (the token, or the AppRole id and secret) is sealed under +the instance key, rewrapped by a key rotation with everything else, and never +shown by any call. Leave it out of a change and the stored one stays. + +`POST /api/v1/secret-providers/{provider}/test` signs in and, with a `path`, +reads it, and says whether it worked. It never returns a value. `GET +/api/v1/secret-providers` lists providers for anyone signed in, because the +people who build credentials need the names. The calls are in the audit log as +`secrets.provider_created`, `_updated` and `_deleted`. + +### What it won't do + +- **A provider needs allowed paths.** A token for Vault can usually read far + more than anyone who edits credentials should be able to name, and without a + list, anyone who can make a credential could point one at any secret the token + can reach and have a workflow send it somewhere. Give the provider the paths + its credentials need and its policy the same. +- **A reference is checked when the credential is saved**, not only when a run + uses it: the provider has to exist, be open to the credential's project, and + allow the path. A path with an empty part, a dot, or a `..` is refused. +- **A run that can't read the secret fails and says why**, naming the provider, + the path and the key and never a value: no such provider, a path the + provider doesn't allow, no secret there, no such key, or the server's own + refusal (a policy that doesn't allow the read is "permission denied"). +- **Deleting a provider doesn't edit credentials.** Ones that point at it fail + when a run opens them, saying the provider is gone. +- A secret value that really begins with `secret://` can't be stored as a + secret, because it reads as a reference. +- Values that are numbers or true or false are read as text. A value that is a + whole object or a list is refused. +## Log streaming + +In the editor: Settings, Log streaming (owners and admins). Each destination shows how much it has sent, when it last delivered and its last error, with a button that sends one test event. + +Send the audit log, and a line for every run that finishes, to the places a +plant already keeps its logs: a syslog server, or anything that takes OTLP logs +over HTTP (an OpenTelemetry collector, Loki, a log platform). Events leave as +they happen, so a SIEM sees a failed sign-in in seconds, and the audit trail +exists somewhere an administrator of this instance can't rewrite. + +A destination is one place. Make as many as you need: + +```sh +curl -X POST $CASCADE/api/v1/log-destinations -H "X-CSRF-Token: ..." -d '{ + "name": "plant syslog", "kind": "syslog", + "address": "syslog.plant.example:6514", "network": "tls", + "caCert": "-----BEGIN CERTIFICATE-----..." +}' +curl -X POST $CASCADE/api/v1/log-destinations -H "X-CSRF-Token: ..." -d '{ + "name": "collector", "kind": "otlp", "endpoint": "https://otel.plant.example/v1/logs", + "headers": {"Authorization": "Bearer ..."} +}' +``` + +Making, changing and removing destinations is for instance owners and admins +(`logstream.manage`), by a person signed in and not by an API key, because a +destination is where the audit log goes. The calls are in the audit log as +`logstream.destination_created`, `_updated` and `_deleted`. + +### What is sent + +- **Audit events**: every row of the audit log, with its sequence number and its + hash. The sequence number and the hash are how a receiver spots a repeat, and + the hash is the row's own, so the chain can be checked from what arrived. A + failed or refused sign-in is a warning, everything else a notice. +- **Execution events**: one for each run that finishes, whatever way it ends: + the run's id, the workflow's id and name, its project, status, mode, when it + was created, started and finished, how long it ran, and its error and the step + it failed at. A failed run is an error, a stopped one a notice, a good one + informational. They are sent a couple of seconds after the run ends. +- **Never item data.** What a run's steps saw and made stays in the run, where + redaction policies protect it. The audit log never holds a secret. + +`events` picks one or both (`["audit"]`, `["execution"]`, the default both). +`start` says where a new destination begins: `now`, the default, sends what +happens after it is made, and `beginning` sends the whole audit log and every +finished run first. + +### Syslog + +A message is RFC 5424: `1 time host hotloop-cascade pid msgid - {json}`, +with the event as one line of JSON. The priority is the facility times eight plus +the severity. The msgid is the audit action (`workflow.saved`) or the run's +status. + +| Field | What it does | +|---|---| +| `address` | `host:port`. | +| `network` | `tcp` (the default), `udp` or `tls`. | +| `framing` | For `tcp` and `tls`: `octet` (RFC 6587 octet counting, the default) or `newline`. UDP is one datagram each. | +| `facility` | `local0` by default; any syslog facility name. | +| `appName` | The header's APP-NAME, `hotloop-cascade` by default. | +| `caCert`, `serverName` | For `tls`: the authority to trust, and the name the certificate has to be for when it isn't the host. There is no setting that skips the check. | + +### OTLP + +Logs go to `endpoint` as OTLP over HTTP with a JSON body: a log record for each +event, its body the event as JSON, and its parts as attributes +(`cascade.event`, `cascade.action`, `cascade.status`, `cascade.project`, +`cascade.actor.name` and so on) so a receiver can filter without parsing. The +resource says `service.name=hotloop-cascade`, its version and the host. +`headers` are sent with every request, for a token or a tenant. They are sealed +under the instance key, rewrapped by a key rotation, and never shown: the list +says only their names. `caCert` and `serverName` work as for syslog. + +### How delivery behaves + +- **At least once, in order.** A destination remembers the last audit row and + the last finished run it sent. One that is down gets everything it missed when + it comes back, in order, and a restart carries on where it stopped. If the + sender is cut off between sending and noting it, those events are sent again, + which is what the sequence numbers are for. +- **A failing destination backs off**, one second and doubling to five minutes, + and its status says what went wrong and when: `lastError`, `lastErrorAt`. A + success clears it. Nothing is dropped while it is down, so the audit log is the + limit, not a queue. +- **One sender at a time.** With several mains, whichever holds a destination's + lease sends to it; the others wait and take over if it stops. +- **Changing a destination keeps its place.** Moving it to another address never + resends or skips anything. Headers left out of a change are kept, and an empty + object clears them. The name and kind can't change. +- `POST /api/v1/log-destinations/{destination}/test` sends one made-up event + through the same path and says whether it arrived, with the reason when it + didn't. It moves nothing. +- `GET /api/v1/log-destinations` shows each one's status: how many events it has + sent, the last audit sequence number delivered, when it last succeeded, and its + last error. + +## Where large data lives + +Files and big payloads that a run carries (a downloaded PDF, a camera frame, a +spreadsheet) are stored as blobs, apart from the database. By default they sit +on the instance's disk. Set `HOTLOOP_CASCADE_BLOB_STORE` to put them in an S3 +bucket or an Azure blob container instead, so the main can be rebuilt without +carrying them and several mains share one copy. + +| Setting | Meaning | +| --- | --- | +| `HOTLOOP_CASCADE_BLOB_STORE` | `fs` (the default), `s3`, or `azure`. | +| `HOTLOOP_CASCADE_BLOB_SPOOL_DIR` | Where a blob waits while it is hashed before upload. Defaults to `blob-spool` in the data directory. | +| `HOTLOOP_CASCADE_BLOB_S3_BUCKET`, `_ACCESS_KEY`, `_SECRET_KEY` | Required for `s3`. The secret also reads `_SECRET_KEY_FILE`. | +| `HOTLOOP_CASCADE_BLOB_S3_ENDPOINT`, `_REGION` | Leave the endpoint out for AWS. Set it for Garage, MinIO or Ceph. | +| `HOTLOOP_CASCADE_BLOB_S3_PATH_STYLE` | `true` or `false`. Path style is the default whenever an endpoint is set. | +| `HOTLOOP_CASCADE_BLOB_S3_CA_FILE` | A CA bundle for an endpoint with a private certificate. | +| `HOTLOOP_CASCADE_BLOB_S3_PREFIX` | A folder inside the bucket, so one bucket can hold more than one instance. | +| `HOTLOOP_CASCADE_BLOB_AZURE_CONTAINER` | Required for `azure`. | +| `HOTLOOP_CASCADE_BLOB_AZURE_CONNECTION_STRING`, or `_ACCOUNT` with `_KEY`, or `_SAS` | One way to sign in. Secrets also read a `_FILE` form. | +| `HOTLOOP_CASCADE_BLOB_AZURE_ENDPOINT`, `_PREFIX` | An endpoint for Azurite or a private cloud, and a folder inside the container. | + +On the chart, set these through `env` and `extraEnv`, with the secrets coming from +a Kubernetes Secret. + +- **Addressed by content.** A blob is stored under its SHA-256 (`sha256/ab/cdef...`), + so the same file is stored once however many runs carry it. Putting it again is + a no-op, including when two runs do it at the same moment. +- **Hashed before upload.** The data is read once into the spool file, hashed, + and then sent, so memory stays flat for any size. A blob over the size limit is + refused, and a cancelled upload leaves nothing behind in the bucket. +- **Checked on the way out.** A read is verified against its hash as it streams, + so a corrupted object fails loudly instead of handing a run bad data. +- **Size.** A single upload goes up as a multipart transfer on S3 and in blocks on + Azure, so the usual 5 GiB single-request limit does not apply. +- **Changing store later.** Existing blobs stay where they were written. Move + them to the new store before switching, because a reference to a blob the new + store lacks is a missing file. + +These two backends are tried against Garage and Azurite in the test stack, the +same conformance suite as the disk store: put and read, empty blobs, dedupe under +concurrency, corruption, the limit, bad ids, delete, list, a cancelled put, and a +192 MiB streamed upload with a heap check. + +What it means for you: a rebuilt or replaced main finds every file its runs +carried, with no copying. diff --git a/src/content/docs/cascade/identity.md b/src/content/docs/cascade/identity.md new file mode 100644 index 0000000..0e42fd2 --- /dev/null +++ b/src/content/docs/cascade/identity.md @@ -0,0 +1,311 @@ +--- +# generated by scripts/sync-docs.mjs +title: "Identity and access" +description: "Custom roles, sign-in with LDAP, Active Directory and SAML, and SCIM provisioning, all in every Cascade with no paid tier." +editUrl: false +--- + +Who gets in, and what they can do once they are. Cascade ships all of this to +everyone. Nothing here is a paid tier and there is no license check behind it. + +Every endpoint below is in the OpenAPI document at `/api/v1/openapi.json`, and +every one has a test that calls it. + +## Custom roles + +In the editor: Settings, Roles. Everyone reads the roles; an owner or admin makes, +changes and deletes custom ones, and gives them to people in a project from the +same screen. + +A project has four built in roles: admin, editor, operator and viewer. They live +in code and they never change. When none of them is the right size, make a +custom role: a name and a list of project permissions. + +A line lead who can start the line's workflows and read their runs, and can't +edit anything, is the usual reason: + +```sh +curl -X POST $CASCADE/api/v1/roles -H "Authorization: Bearer $KEY" \ + -d '{"name":"line-lead","description":"runs the line","permissions":["project.read","workflow.read","workflow.execute","execution.read"]}' +``` + +Then put someone in a project as that role, the same call as for a built in one: + +```sh +curl -X PUT $CASCADE/api/v1/projects/$PROJECT/members/$USER -d '{"role":"line-lead"}' +``` + +What to know: + +- `GET /api/v1/permissions` lists every permission a role can carry, and + `GET /api/v1/roles` lists the four built in roles and the custom ones with + what each can do. Anyone signed in can read both, because a project admin + needs them to pick a role. +- Making, changing and deleting a custom role is an instance owner or admin + thing, the `role.manage` permission. A project admin can hand a role to + people in their project and can't write a new one, so nobody grants + themselves more than they were given. +- A role carries project permissions only. It can't carry `user.manage`, + `audit.read` or any other instance permission, and asking for one is a 400. +- A name is 2 to 40 lowercase letters, digits and dashes, starting with a + letter. It can't be changed afterward, because members and service accounts + hold a role by its name. The four built in names are taken. +- Changing a role's permissions reaches everyone who holds it on their next + request. Nothing is cached, so there is no window where a removed permission + still works. +- A service account key can hold a custom role as its project role, and an + API key's scopes still cut it down. +- Deleting a role somebody holds is refused with 409, naming how many members + and service account keys hold it. Move them first. +- Role changes are in the audit log as `role.created`, `role.updated` (with the + permissions before and after) and `role.deleted`. + +## LDAP and Active Directory + +People sign in with the name and password they already have at the plant: +OpenLDAP, 389 Directory Server, FreeIPA, or Active Directory. It sits next to +the local accounts and next to OIDC, and it is off until you set +`HOTLOOP_CASCADE_LDAP_URL`. + +How a sign-in goes: + +1. A service account (or an anonymous search, if your directory allows it) finds + the person with the user filter. +2. It looks up the groups they are in. +3. The directory itself checks the password, by binding as the person. Cascade + never stores that password and never sees a hash of it. The account made here + has no password of its own, so the directory password can't be used on + `/login` as a local one. +4. The account is made on first sign-in (unless you turn that off), and their + role follows their groups at every sign-in after. + +The sign-in page shows a second form when it is on, and the same thing is one +call, `POST /auth/ldap/login` with `{"username": "...", "password": "..."}`. A +wrong name and a wrong password get the same answer. An empty password never +reaches the directory, because most directories answer an empty-password bind as +success. + +### Settings + +All of them are environment variables. With the Helm chart, put them in `env`, +and the bind password in `extraEnv` from a secret. + +| Variable | What it does | +|---|---| +| `HOTLOOP_CASCADE_LDAP_URL` | `ldap://host:389` or `ldaps://host:636`. Turns it on. | +| `HOTLOOP_CASCADE_LDAP_FLAVOR` | `ldap` (default) or `ad`. It only picks the defaults below. | +| `HOTLOOP_CASCADE_LDAP_START_TLS` | `true` upgrades an `ldap://` connection before anything is sent. | +| `HOTLOOP_CASCADE_LDAP_CA_FILE` | PEM file of the authorities to trust for the directory's certificate. Empty means the system's. | +| `HOTLOOP_CASCADE_LDAP_TLS_SERVER_NAME` | The name the certificate has to be for, when it isn't the host in the URL. | +| `HOTLOOP_CASCADE_LDAP_BIND_DN` | The service account that looks people up. Empty is an anonymous search. | +| `HOTLOOP_CASCADE_LDAP_BIND_PASSWORD` or `_FILE` | Its password, or a file holding it. Not both. A DN with no password is refused, since that would be an anonymous bind. | +| `HOTLOOP_CASCADE_LDAP_USER_BASE` | Required. The DN people are searched for under. | +| `HOTLOOP_CASCADE_LDAP_USER_FILTER` | Finds one person. `{username}` is what they typed, escaped. | +| `HOTLOOP_CASCADE_LDAP_GROUP_BASE` | The DN groups are searched for under. Empty means no group lookup. | +| `HOTLOOP_CASCADE_LDAP_GROUP_FILTER` | Finds the groups a person is in. `{dn}` is their DN, `{username}` what they typed. | +| `HOTLOOP_CASCADE_LDAP_ID_ATTR`, `_EMAIL_ATTR`, `_NAME_ATTR` | The attributes for the stable id, the email and the display name. | +| `HOTLOOP_CASCADE_LDAP_ROLES` | `group=role` pairs, comma separated: `supervisors=admin, operators=member`. A group is named by its `cn` or by its full DN. The most powerful role among a person's groups wins. | +| `HOTLOOP_CASCADE_LDAP_DEFAULT_ROLE` | `member`, `admin` or `none`. With a role mapping the default is `none`, so someone in no mapped group is turned away. | +| `HOTLOOP_CASCADE_LDAP_JIT` | `false` makes accounts by hand only. Default `true`. | +| `HOTLOOP_CASCADE_LDAP_NAME` | What the sign-in button calls it. | + +The defaults for `ldap`: user filter `(&(objectClass=inetOrgPerson)(uid={username}))`, +group filter `(&(|(objectClass=groupOfNames)(objectClass=groupOfUniqueNames)(objectClass=group))(member={dn}))`, +id `entryUUID`, email `mail`, name `cn`. + +The defaults for `ad`: a user is found by `sAMAccountName` or by user principal +name, so `alice` and `alice@plant.example` both work, and a disabled account is +left out of the search. Groups use the nested-membership rule +(`member:1.2.840.113556.1.4.1941:={dn}`), so someone in a group that is inside a +mapped group gets the role too. The id is `objectGUID`, written as a GUID, email +`mail`, name `displayName`. + +### Things to know + +- **Active Directory wants encryption.** It refuses a simple bind on a plain + connection. Use `ldaps://`, or `ldap://` with `START_TLS=true`. Cascade + checks the certificate against the CA file (or the system roots) and against + the server name, and there is no setting that skips the check. A directory + that signs its own certificate needs its CA in `CA_FILE`. +- **The account is tied to the directory's id, not the name.** Rename someone in + the directory, or move them to another OU, and they are the same account + here. Someone with no `mail` can't be given an account (the sign-in says so). +- **The directory is the authority for the address.** An account that already + exists here with the same email is linked to the directory entry the first time + that person signs in, with no extra verification, the way a verified OIDC + address is. Don't point this at a directory you don't run. +- **The first account an instance gets is its owner**, however it is made. + Make the owner with setup first. +- **A second factor set up here still counts.** A directory password is one + factor. Someone with an authenticator app or a passkey on their account is + asked for it after, and instance-wide MFA enforcement applies as it does to + everyone. +- **Wrong passwords lock the account here too.** Five in a row on an account + that is already tied to the directory locks it for a minute, doubling, the same + as a local one, before the directory's own lockout has to. A person the + directory doesn't know, a wrong password and a locked account are all in the + audit log as `auth.login_failed` or `auth.login_refused`, and a person the + directory let through that Cascade turned away (no group, no email, JIT off) is + `auth.sso_refused`. +- **A directory that is down is a server error, not a wrong password.** The + sign-in says the directory didn't answer, and the detail is in the log. +- Every call to the directory is limited to fifteen seconds, and only one person may match the user + filter: two matches is a refusal naming the problem, never the first one. + +## SAML + +Single sign-on through any SAML 2.0 identity provider. Cascade is the service +provider. It sits next to local +accounts, OIDC and LDAP, and it is off until you name the provider's metadata. + +The steps: + +1. Cascade publishes its own metadata at `/auth/saml/metadata`. Give that to the + provider, or enter the two values it holds: the entity id, and the assertion + consumer service, `/auth/saml/acs`, HTTP-POST binding. +2. Set `HOTLOOP_CASCADE_SAML_IDP_METADATA_URL` to the provider's metadata + address (something like `https://sso.plant.example/saml/metadata`), + or `HOTLOOP_CASCADE_SAML_IDP_METADATA_FILE` to the same document saved to disk. +3. The sign-in page shows a "Sign in with" button. It goes to `/auth/saml/start`. + +What it checks on the way back: the response's signature against the provider's +certificate in its metadata, the audience, the time window, that it answers a +request this browser started, and that the sign-in was started in this browser. +A response nobody asked for (a sign-in started at the provider) is refused. Each +sign-in's state works once, so a captured response can't be played again. + +| Variable | What it does | +|---|---| +| `HOTLOOP_CASCADE_SAML_IDP_METADATA_URL` or `_FILE` | The provider's metadata. One of them turns SAML on; not both. | +| `HOTLOOP_CASCADE_SAML_ENTITY_ID` | How this instance names itself to the provider. Default: its metadata URL. | +| `HOTLOOP_CASCADE_SAML_KEY_FILE` and `_CERT_FILE` | This instance's key pair, PEM, RSA or EC. Both or neither. Default: a pair made on first start as `saml-sp.key` and `saml-sp.crt` in the data directory, and kept. | +| `HOTLOOP_CASCADE_SAML_SIGN_REQUESTS` | `true` signs every request Cascade sends. The provider has to be told the certificate. Default `false`. | +| `HOTLOOP_CASCADE_SAML_NAMEID_FORMAT` | What Cascade asks the provider to identify people by: `persistent` (default), `email` or `unspecified`. | +| `HOTLOOP_CASCADE_SAML_SUBJECT_ATTR` | An attribute that identifies the person instead of the NameID. | +| `HOTLOOP_CASCADE_SAML_EMAIL_ATTR`, `_NAME_ATTR`, `_GROUPS_ATTR` | Which attributes hold the email, the display name and the groups. | +| `HOTLOOP_CASCADE_SAML_ROLES`, `_DEFAULT_ROLE`, `_JIT`, `_NAME` | The same as for LDAP: group to role pairs, the role for anyone in none, whether an account is made on first sign-in, and the button's name. | + +Without the attribute settings Cascade looks for the common names. The email is +`email`, `mail`, `emailAddress`, the LDAP object id `urn:oid:0.9.2342.19200300.100.1.3` +or the claim URI `.../claims/emailaddress`, and falls back to the NameID when +that is an address. The name is `displayName`, `name`, or the first and last +name. Groups are `groups`, `group`, `memberOf`, the `.../claims/Group` URI and +the Microsoft groups claim. + +Things to know: + +- **Ask for a persistent id.** An account is tied to the NameID, and a transient + one is different at every sign-in. Cascade asks for a persistent one and + refuses a transient one with a message saying so, rather than making a new + account at every sign-in. If your provider can only send a transient id, name + an attribute that is stable in `SUBJECT_ATTR`. +- **The provider is the authority for the address.** As with LDAP, an existing + account with the same email is linked on the first SAML sign-in. +- **Behind a proxy, set `HOTLOOP_CASCADE_PUBLIC_URL` to the address people use.** + The provider posts to the assertion consumer URL built from it. +- **Use HTTPS.** The provider answers with a POST from another site, so the + cookie that ties a sign-in to its browser has to be sent cross-site, which + browsers allow only on a secure connection. +- **More than one pod** needs the same key pair on all of them: put it in a + secret and name it with `KEY_FILE` and `CERT_FILE`, or each pod makes its own. +- A second factor set up here still counts after a SAML sign-in, and SAML never + makes the provider's own multi-factor count for it. +- Single logout isn't supported. Signing out ends the session here. + +## SCIM + +In the editor: Settings, Sign-in and SCIM shows whether SCIM is on, the base URL to +give the provider, and the tokens, with a button to make one (shown once) and to +revoke one. LDAP, SAML and OpenID Connect are set in the instance's environment +when it starts, so the same screen shows which are on and where a provider +points (the SAML metadata and ACS URLs), and doesn't pretend to change them. + +SCIM is how an identity provider keeps accounts in step with its directory: it +creates a person when they join, changes them when they move, switches them off +when they leave, and keeps groups. Cascade is the SCIM server (RFC 7643 and +7644, version 2.0), and the provider is the client. It is built to what the RFCs +say and to what the common identity providers send, and it is tried in every release against +scim2-tester, a SCIM client from another team that runs every check it has. +It's off until you set `HOTLOOP_CASCADE_SCIM=true`. + +Set it up: + +1. Turn it on with `HOTLOOP_CASCADE_SCIM=true` and restart. +2. Make a token for the provider, as an instance owner or admin: + `POST /api/v1/scim-tokens` with `{"name": "plant directory"}`. The `secret` in the answer + (it starts `hlcs_`) is shown once, so put it in the provider straight away. + `GET /api/v1/scim-tokens` lists them with when each was last used, and + `DELETE /api/v1/scim-tokens/{token}` revokes one. A person has to be signed in + to make a token: an API key can't, so a leaked key can't mint more. +3. Give the provider the base URL, `/scim/v2`, and the token as a + bearer token. + +Every call needs the token, and every answer is `application/scim+json`. Calls +are in the audit log with the token's name as the actor, so `user.created`, +`user.disabled` and the rest say which provider did it, along with the SCIM +events `scim.user_deleted`, `scim.group_created`, `scim.group_updated` and +`scim.group_deleted`. + +### What it serves + +`/ServiceProviderConfig`, `/ResourceTypes` and `/Schemas` say what is supported, +and they say only what is true: filtering, PATCH, no bulk, no sorting, no ETags, +no password changes. + +| Resource | Calls | +|---|---| +| `/Users` | `GET` (list, with `filter`, `startIndex`, `count`, `attributes`, `excludedAttributes`), `POST`, `GET /{id}`, `PUT /{id}`, `PATCH /{id}`, `DELETE /{id}` | +| `/Groups` | the same | +| `/.search`, `/Users/.search`, `/Groups/.search` | `POST` with the query in the body | + +- **A user** is `userName`, `externalId`, `name` (`formatted`, `givenName`, + `familyName`), `displayName`, `emails` and `active`. The account's email is the + primary one, else the work one, else the first, else `userName` when that is an + address. Anything without an address is a 400. What the provider sends is + returned as it sent it: a name it didn't send isn't made up. `groups` comes + back read-only. +- **A group** is `displayName`, `externalId` and `members`, each member the id of + a user. A member that isn't a user is a 400. +- **Filters** are the full syntax: `eq ne co sw ew gt ge lt le pr`, `and`, `or`, + `not`, parentheses and `attr[filter]`. Matching ignores case except for `id` + and `externalId`. A bad filter is a 400 with `scimType: invalidFilter`. +- **PATCH** takes `add`, `replace` and `remove` with or without a path, with + value paths like `emails[type eq "work"].value` and `members[value eq "id"]`, + in any capitalization, and a boolean as `true` or as the string `"False"` + that some providers send. It changes exactly what a PUT could change. +- **Lists** are in the order accounts were made, 100 to a page unless `count` + says, 200 at most. + +### What it won't do + +- **It never shows or changes the owner.** The instance owner isn't in the list, + can't be fetched, patched or deleted, and can't be put in a group. An email + that belongs to the owner is a conflict. +- **It never deletes an account.** `DELETE` switches the person off, which ends + their sessions, takes them out of their groups and hides them from SCIM. + Everything they made and every audit row stays. If the provider creates them + again with the same address, the same account comes back, switched on. + `active: false` does the switching off without hiding them. +- **A SCIM account has no password.** It signs in through OIDC, SAML or LDAP, + which link to it by the address. +- **It doesn't touch projects.** SCIM works with accounts and instance roles. + Who is in which project is still decided here. + +### Roles from groups + +By default a new account is a member and SCIM never changes anyone's role. To make +groups decide it, map group names to roles: + +``` +HOTLOOP_CASCADE_SCIM_ROLES="Plant Admins=admin, Plant Members=member" +HOTLOOP_CASCADE_SCIM_DEFAULT_ROLE=member +``` + +A person's role then follows their groups every time a membership changes: the +most powerful mapped role wins, and someone in none gets the default. Only +`admin` and `member` can be mapped, because a provider never makes an owner. +Use one source of roles. If OIDC or SAML also maps groups to roles, whichever +last changed a person's role wins, and the two will fight. + +The provider needs the same group names, and has to push groups with their +members, which every provider that does SCIM groups can. diff --git a/src/content/docs/cascade/running.md b/src/content/docs/cascade/running.md index 70a8d9d..f274de6 100644 --- a/src/content/docs/cascade/running.md +++ b/src/content/docs/cascade/running.md @@ -5,6 +5,10 @@ description: "Run Cascade with Podman Quadlet or Helm on k3s, on SQLite or Postg editUrl: false --- +:::note[Written against main] +This page follows `main` at dd8af77, and it differs from the copy in HotLoop Cascade 1.0.1, the latest release. Parts of it may be newer than what you have installed. The [release notes](/releases/) say what moved. +::: + On a Podman host, `deploy/quadlet` has systemd units. One file and a volume is Cascade on SQLite; `deploy/quadlet/postgres` is the server, a worker and PostgreSQL on a network of their own. Both start at boot, restart on failure and @@ -14,7 +18,7 @@ it. To pull a version by hand: ```sh -podman pull ghcr.io/hotloop-io/hotloop-cascade:0.1.2 +podman pull ghcr.io/hotloop-io/hotloop-cascade:1.0.1 ``` The units follow `:stable` and Podman's auto-update timer moves them. @@ -22,7 +26,7 @@ The units follow `:stable` and Podman's auto-update timer moves them. On k3s, `charts/hotloop-cascade` is the Helm chart: ```sh -helm install cascade oci://ghcr.io/hotloop-io/charts/hotloop-cascade --version 0.1.2 +helm install cascade oci://ghcr.io/hotloop-io/charts/hotloop-cascade --version 1.0.1 ``` The chart carries the labels the EmberNet dashboard reads, on the Service and on diff --git a/src/content/docs/cascade/status.md b/src/content/docs/cascade/status.md index 8fb3d49..a09caa5 100644 --- a/src/content/docs/cascade/status.md +++ b/src/content/docs/cascade/status.md @@ -5,20 +5,25 @@ description: "Cascade is released: what is in it, what is not in it yet, and whe editUrl: false --- -0.1.2 is the release out today. It was the first one that isn't a candidate: there -was a 0.1.0 tag that never published anything, because its chart test failed on a -hosted runner and I cancelled the run, so nothing reached the registry. +:::note[Written against main] +This page follows `main` at dd8af77, and it differs from the copy in HotLoop Cascade 1.0.1, the latest release. Parts of it may be newer than what you have installed. The [release notes](/releases/) say what moved. +::: -Main has moved past it, and the next release is 1.0.0. It has everything in 0.1.2 -plus the identity and governance work, each piece tried against the real server it +1.0.1 is the release out today, and 1.0.0 is the one before it. 1.0.0 is the first +with the identity and governance work, each piece tried against the real server it talks to: SAML (against Keycloak), LDAP and Active Directory (OpenLDAP and a Samba domain controller), SCIM (against scim2-tester), custom roles, external secrets (OpenBao), log streaming (a syslog server and Loki), variables, folders, insights, execution annotations, redaction policies, and S3 and Azure blob stores (Garage and -Azurite). And it has a new editor, so every one of those has a screen. What isn't -here yet (environments, the HA scheduler with leases, more connectors, AI steps) is -in `docs/ROADMAP.md`. +Azurite). It also has a new editor, so every one of those has a screen. 1.0.1 fixes +one mistake in sign-in: when the database could not be read, Cascade answered "not +signed in" and cleared the cookie, so a short outage signed everyone out and could +turn a good second factor into a wrong code. Now it says to try again and keeps the +session. Nothing migrates between the two. + +What isn't here yet (Git source control and environments, more connectors, AI steps +and an MCP server) is in `docs/ROADMAP.md`. The repository is private. The image and the chart are public and pull with no -login. The image is `ghcr.io/hotloop-io/hotloop-cascade`, with tags `0.1.2`, -`0.1`, `0`, `latest` and `stable`, for amd64 and arm64. +login. The image is `ghcr.io/hotloop-io/hotloop-cascade`, with tags `1.0.1`, +`1.0`, `1`, `latest` and `stable`, for amd64 and arm64. diff --git a/src/content/docs/install/index.md b/src/content/docs/install/index.md index f390248..e37943c 100644 --- a/src/content/docs/install/index.md +++ b/src/content/docs/install/index.md @@ -26,7 +26,7 @@ There are two ways to run a HotLoop product for real: Helm on k3s or any Kuberne | [HotLoop Gateway](/gateway/overview/) | 4.18.11 | Yes | Yes | [Install the Gateway](/gateway/install/) | | [HotLoop Edge Relay](/edge-relay/overview/) | 4.18.11 | Yes | Yes | [Install the Edge Relay](/edge-relay/install/) | | [HotLoop Flow](/flow/overview/) | 2.7.0 | Yes | Yes | [Install Flow](/flow/install/) | -| [HotLoop Cascade](/cascade/overview/) | 1.0.0 | Yes, no login needed | Yes, no login needed, see [Cascade with Podman Quadlet](/cascade/quadlet/) | [Running Cascade](/cascade/running/) | +| [HotLoop Cascade](/cascade/overview/) | 1.0.1 | Yes, no login needed | Yes, no login needed, see [Cascade with Podman Quadlet](/cascade/quadlet/) | [Running Cascade](/cascade/running/) | HotLoop IoT, Edge, Gateway, and the Edge Relay come out of one tag, in lockstep, so they share a version number. Flow and Cascade have their own. The IoT, Edge, and Gateway Quadlet units need Podman 5.0 or newer, and the relay's unit was run on Podman 4.9.3. Raspberry Pi OS Bookworm ships 4.3, which has no Quadlet at all, so a Pi wants Trixie or newer. diff --git a/src/content/docs/releases/cascade-1-0-1.md b/src/content/docs/releases/cascade-1-0-1.md index af1084e..05f899a 100644 --- a/src/content/docs/releases/cascade-1-0-1.md +++ b/src/content/docs/releases/cascade-1-0-1.md @@ -5,15 +5,8 @@ description: "What shipped in HotLoop Cascade 1.0.1, what changed on upgrade, an editUrl: false sidebar: label: "1.0.1" - badge: - text: Next release - variant: caution --- -:::caution[On main, next release] -This page was written from `main` at 0b6fbd5. These notes are for a release that is not out. HotLoop Cascade 1.0.0 is the latest one you can install. -::: - ## What this is 1.0.1 fixes one mistake in how sign-in treats a database that can't be read. Nothing else changed from 1.0.0. diff --git a/src/content/docs/releases/index.md b/src/content/docs/releases/index.md index c769d96..96dc61e 100644 --- a/src/content/docs/releases/index.md +++ b/src/content/docs/releases/index.md @@ -5,7 +5,7 @@ description: "What shipped in every HotLoop core, HotLoop Flow and HotLoop Casca editUrl: false --- -Every release has its own page, written the day it shipped. A note marked "on main, next release" describes a release that is not out yet, so nothing in it is in what you have installed. The latest HotLoop core release is 4.18.11, the latest Flow release is 2.7.0, and the latest Cascade release is 1.0.0. +Every release has its own page, written the day it shipped. A note marked "on main, next release" describes a release that is not out yet, so nothing in it is in what you have installed. The latest HotLoop core release is 4.18.11, the latest Flow release is 2.7.0, and the latest Cascade release is 1.0.1. ## HotLoop core @@ -29,7 +29,7 @@ IoT, Edge, Gateway and Edge Relay release together from one tag, so they share t ## HotLoop Cascade -- [HotLoop Cascade 1.0.1 release notes](/releases/cascade-1-0-1/) (on main, next release) +- [HotLoop Cascade 1.0.1 release notes](/releases/cascade-1-0-1/) - [HotLoop Cascade 1.0.0 release notes](/releases/cascade-1-0-0/) - [HotLoop Cascade 0.1.2 release notes](/releases/cascade-0-1-2/) - [HotLoop Cascade 0.1.1 release notes](/releases/cascade-0-1-1/) diff --git a/src/data/sync.json b/src/data/sync.json index c2c8dc6..7b0661f 100644 --- a/src/data/sync.json +++ b/src/data/sync.json @@ -2,7 +2,7 @@ "repos": { "hotloop": { "slug": "HotLoop-io/hotloop", - "ref": "origin/main", + "ref": "4d5da2f216e276bba9587f9aa5e3343bad13258f", "sha": "4d5da2f216e276bba9587f9aa5e3343bad13258f", "release": { "tag": "v4.18.11", @@ -14,7 +14,7 @@ }, "flow": { "slug": "HotLoop-io/hotloop-flow", - "ref": "origin/main", + "ref": "b1d8cc56826e6466c465d6b2473482b00d804f8e", "sha": "b1d8cc56826e6466c465d6b2473482b00d804f8e", "release": { "tag": "v2.7.0", @@ -27,13 +27,13 @@ "cascade": { "slug": "HotLoop-io/hotloop-cascade", "ref": "origin/main", - "sha": "0b6fbd5c0b593ccaf325667c2e0a0c1cb0869dac", + "sha": "dd8af77a63260c4ead704ef801b59865f288a7c6", "release": { - "tag": "v1.0.0", - "version": "1.0.0", + "tag": "v1.0.1", + "version": "1.0.1", "prerelease": false, - "publishedAt": "2026-11-01T19:16:03Z", - "sha": "6a3bdbe1d68e3ed92a594a0a66ee481d4dbc5fe8" + "publishedAt": "2026-11-01T20:06:03Z", + "sha": "0b6fbd5c0b593ccaf325667c2e0a0c1cb0869dac" } } }, @@ -775,7 +775,7 @@ "group": "Start here", "title": "Where Cascade stands", "label": null, - "status": "released", + "status": "changed", "repo": "cascade", "source": "README.md", "release": false @@ -797,7 +797,7 @@ "group": "Use it", "title": "Running Cascade", "label": null, - "status": "released", + "status": "changed", "repo": "cascade", "source": "README.md", "release": false @@ -835,6 +835,28 @@ "source": "README.md", "release": false }, + { + "slug": "cascade/identity", + "product": "cascade", + "group": "Identity", + "title": "Identity and access", + "label": null, + "status": "released", + "repo": "cascade", + "source": "docs/IDENTITY.md", + "release": false + }, + { + "slug": "cascade/governance", + "product": "cascade", + "group": "Governance", + "title": "Governing a Cascade instance", + "label": null, + "status": "released", + "repo": "cascade", + "source": "docs/GOVERNANCE.md", + "release": false + }, { "slug": "cascade/quadlet", "product": "cascade", @@ -1039,7 +1061,7 @@ "group": "HotLoop Cascade", "title": "HotLoop Cascade 1.0.1 release notes", "label": "1.0.1", - "status": "next", + "status": "released", "repo": "cascade", "source": "docs/release-notes/1.0.1.md", "release": true