Released docs. You are viewing the documentation published with v0.34.0. Development docs are available at Latest.
If you finished Extend a registry with a module, you have bregctl
installed and a tutorial-work directory. This tutorial builds from a fresh project of its own,
tutorial-work/candidate-project, because the module tutorial edits its project and every edit
changes the digests. In this tutorial you read what the production profile finds in the new project,
run its journeys against a PostgreSQL you start yourself, and turn the result into a package signed
with a key you create and verified the way the runtime verifies it at startup. You end with a
verified package on disk, not a running registry: Deploy a registry takes it
from there.
Before you start
Section titled “Before you start”Open a terminal in the directory that holds tutorial-work, or in any directory you want to work
in. Create the project this tutorial builds from:
mkdir -p tutorial-workbregctl init tutorial-work/candidate-projectinit writes a project for bregctl dev, so its package block names local, the unsigned
environment. A candidate is built for a named environment and must be signed. Open
tutorial-work/candidate-project/registry.yaml and change the environment line in that block:
package: environment: developmentThe runtime configuration and the trust anchor you write later name the same environment.
Confirm the tools. The signing steps need an OpenSSL that signs with Ed25519; the LibreSSL that macOS
ships as /usr/bin/openssl does not, so install OpenSSL 3 and put it first on PATH.
bregctl --versiondocker --versionopenssl versionSet an owner-only file mode for this shell, so every key, password, and token the commands create is readable by you alone:
umask 077Every other file this tutorial creates sits under tutorial-work/candidate. The digests shown on this
page come from the bregctl init project with only that environment change; if you edit anything else
in candidate-project, yours differ, and that is expected.
Read the production findings
Section titled “Read the production findings”check --production compiles the project the way test and package do. It refuses what the
production profile forbids and reports findings, which are decisions it wants a person to review
rather than errors:
bregctl check --production tutorial-work/candidate-projectProduction check passed. revision sha256:47bb7234a81ecd44fef51323c6d92f3291282b5faebb6645a9096f16b9059d5a
finding access.profile.unrestricted_collection entities[id=record].accessProfiles[id=operator].rowBoundaries this profile can list all rows, subject only to query bounds; caller filters are not authorization. Add a claim-bound row restriction or review this registry-wide access finding access.profile.unrestricted_rows entities[id=record].accessProfiles[id=evidence-source].rowBoundaries this profile has no claim-bound row restriction for its granted operations; requestVisibility owner limits request reads only, and other lifecycle rules still apply. Review this registry-wide access
0 errors, 2 findings.The generated project ships both findings on purpose: the operator profile lists every record because
one operations team runs the whole registry, and the evidence-source profile looks a record up by its
code for any record in the registry. The comment that introduces each profile in registry.yaml says
how to close it (a rowBoundaries entry, or removing list from the operator’s grant).
Closing them would also change the journeys, so treat them as reviewed and see what a stricter gate does
with them:
bregctl check --production --deny-findings tutorial-work/candidate-projectbregctl check refused.
finding access.profile.unrestricted_collection entities[id=record].accessProfiles[id=operator].rowBoundaries this profile can list all rows, subject only to query bounds; caller filters are not authorization. Add a claim-bound row restriction or review this registry-wide access finding access.profile.unrestricted_rows entities[id=record].accessProfiles[id=evidence-source].rowBoundaries this profile has no claim-bound row restriction for its granted operations; requestVisibility owner limits request reads only, and other lifecycle rules still apply. Review this registry-wide access
refused on 2 findings.The command exits with status 1. That is the gate for a pipeline that must never build a candidate with an unreviewed finding; the rest of this tutorial runs without it.
Create the keys
Section titled “Create the keys”Two Ed25519 keys play two roles. The package signer signs the package, and its public key becomes the trust anchor the runtime checks. The issuer key stands in for your identity provider: it signs the access tokens the journeys send, and its public half is the static JWKS the runtime reads.
mkdir -p tutorial-work/candidate/secrets tutorial-work/candidate/postgresopenssl genpkey -algorithm ED25519 -out tutorial-work/candidate/package-signer.pemopenssl genpkey -algorithm ED25519 -out tutorial-work/candidate/issuer.pemissuer_x=$(openssl pkey -in tutorial-work/candidate/issuer.pem -pubout -outform DER | tail -c 32 | openssl base64 -A | tr '+/' '-_' | tr -d '=')printf '{"keys":[{"kty":"OKP","crv":"Ed25519","alg":"EdDSA","kid":"candidate-issuer","x":"%s"}]}' "$issuer_x" > tutorial-work/candidate/secrets/issuer-jwksThe two private keys are the whole trust of this candidate. Never copy them into the project, a package, or a repository. They exist for this exercise and are deleted in the cleanup step.
Start PostgreSQL
Section titled “Start PostgreSQL”The runtime connects to PostgreSQL over TLS only, and the schema test uses the same connection code,
so the container needs a server certificate. Create a small certificate authority and a certificate
for localhost, then start a PostgreSQL 17 container on port 5433 so it
stays clear of a PostgreSQL you may already run:
openssl req -x509 -new -nodes -newkey rsa:2048 -sha256 -days 2 -subj '/CN=Candidate tutorial CA' -keyout tutorial-work/candidate/postgres/ca.key -out tutorial-work/candidate/postgres/ca.pemopenssl req -new -nodes -newkey rsa:2048 -subj '/CN=localhost' -keyout tutorial-work/candidate/postgres/server.key -out tutorial-work/candidate/postgres/server.csrprintf 'subjectAltName=DNS:localhost\n' > tutorial-work/candidate/postgres/server.extopenssl x509 -req -sha256 -days 2 -in tutorial-work/candidate/postgres/server.csr -CA tutorial-work/candidate/postgres/ca.pem -CAkey tutorial-work/candidate/postgres/ca.key -CAcreateserial -extfile tutorial-work/candidate/postgres/server.ext -out tutorial-work/candidate/postgres/server.crtprintf 'POSTGRES_PASSWORD=%s\n' "$(openssl rand -hex 24)" > tutorial-work/candidate/postgres/postgres.envdocker run --detach --name breg-candidate-postgres --env-file tutorial-work/candidate/postgres/postgres.env --publish 127.0.0.1:5433:5432 postgres:17.11@sha256:67f41722b7a8cbdb868a44a4995c846eddfdc2973bccb291ce937dce88ad5675until docker exec breg-candidate-postgres pg_isready -h localhost -U postgres >/dev/null; do sleep 1; donedocker cp tutorial-work/candidate/postgres/server.crt breg-candidate-postgres:/var/lib/postgresql/data/server.crtdocker cp tutorial-work/candidate/postgres/server.key breg-candidate-postgres:/var/lib/postgresql/data/server.keydocker exec breg-candidate-postgres sh -c 'chown postgres:postgres /var/lib/postgresql/data/server.* && chmod 600 /var/lib/postgresql/data/server.key'docker exec breg-candidate-postgres psql -U postgres -c 'ALTER SYSTEM SET ssl = on' -c 'SELECT pg_reload_conf()'The last command prints ALTER SYSTEM and a one-row pg_reload_conf result of t.
Prepare the disposable database
Section titled “Prepare the disposable database”bregctl test needs two ordinary roles and one database it may fill. The migration role owns the
five managed schemas and installs the compiled schema; the runtime role runs the journeys and must
own nothing. Neither may be a superuser, create databases or roles, or bypass row-level security,
which is what the CREATE ROLE options spell out. The test refuses a database whose managed schemas
already hold objects, so the database serves one run: drop and recreate it before every rerun.
mp=$(openssl rand -hex 16)rp=$(openssl rand -hex 16)docker exec -i breg-candidate-postgres psql -v ON_ERROR_STOP=1 -U postgres <<SQLCREATE ROLE registry_migration LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS PASSWORD '$mp';CREATE ROLE registry_runtime LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS PASSWORD '$rp';SQLprintf 'postgresql://registry_migration:%s@localhost:5433/registry_candidate_test' "$mp" > tutorial-work/candidate/secrets/migration-database-urlprintf 'postgresql://registry_runtime:%s@localhost:5433/registry_candidate_test' "$rp" > tutorial-work/candidate/secrets/runtime-database-urlopenssl rand -hex 32 > tutorial-work/candidate/secrets/audit-keyopenssl rand -hex 32 > tutorial-work/candidate/secrets/cursor-keyThe secrets directory now holds the two connection URLs, the audit hash key, the cursor key, and the
JWKS, one owner-only file per secret, which is what the runtime’s file secret provider requires: it
refuses a secret that is not a plain file you own with mode 0400 or 0600. Now the database, in
the block you rerun after every test. The block drops the database before creating it, so a rerun
starts from nothing and everything the previous run wrote is gone:
docker exec breg-candidate-postgres psql -v ON_ERROR_STOP=1 -U postgres -c 'DROP DATABASE IF EXISTS registry_candidate_test' -c 'CREATE DATABASE registry_candidate_test'docker exec -i breg-candidate-postgres psql -v ON_ERROR_STOP=1 -U postgres -d registry_candidate_test <<'SQL'CREATE EXTENSION IF NOT EXISTS btree_gist;REVOKE ALL ON DATABASE registry_candidate_test FROM PUBLIC;GRANT CONNECT ON DATABASE registry_candidate_test TO registry_migration, registry_runtime;CREATE SCHEMA registry_internal AUTHORIZATION registry_migration;CREATE SCHEMA registry_data AUTHORIZATION registry_migration;CREATE SCHEMA registry_source AUTHORIZATION registry_migration;CREATE SCHEMA registry_derived AUTHORIZATION registry_migration;CREATE SCHEMA registry_context AUTHORIZATION registry_migration;REVOKE ALL ON SCHEMA registry_internal, registry_data, registry_source, registry_derived, registry_context FROM PUBLIC;SQLBind the runtime and the credentials
Section titled “Bind the runtime and the credentials”bregctl test reads the runtime configuration document the runtime itself reads at startup, with one
difference: databaseInitializationEnvironment: local marks the database as disposable, so no
package, trust anchor, or signature exists yet. identity must match the project’s package block,
listener.bind is never opened because the journeys are driven in-process, and every path must be
absolute, which the unquoted heredoc arranges by expanding $PWD:
mkdir -p tutorial-work/candidate/empty-packagecat > tutorial-work/candidate/runtime-test.yaml <<EOFapiVersion: registry.registrystack.org/breg-runtime/v1alpha1kind: BRegRuntimeConfiglistener: bind: 127.0.0.1:8080identity: environment: development instanceId: generic-registry-1 databaseId: generic-registry-db-1 databaseInitializationEnvironment: localsecretProviders: file: root: $PWD/tutorial-work/candidate/secretsdatabase: runtimeUrlRef: secret:file/runtime-database-url migrationUrlRef: secret:file/migration-database-url pool: maxSize: 4 roles: migration: registry_migration runtime: registry_runtimepackage: root: $PWD/tutorial-work/candidate/empty-package trustAnchorPath: $PWD/tutorial-work/candidate/package-trust-anchor.json compilerSourceRevision: generic-registry-0.1.0 activeRevision: sha256:1111111111111111111111111111111111111111111111111111111111111111 activeSequence: 1authentication: oidc: issuer: https://issuer.example.invalid audience: generic-registry allowedAlgorithm: EdDSA accessTokenType: at+jwt scopeClaim: scope scopeSeparator: " " allowedClients: - generic-registry-client maxTokenLifetimeSeconds: 3600 leewayMilliseconds: 30000 jwksSource: kind: static documentRef: secret:file/issuer-jwks authorityClaims: principal: registry_principal purpose: registry_purposeaudit: hashKeyRef: secret:file/audit-keycursor: secretRef: secret:file/cursor-keyEOFThe journeys authenticate with bearer tokens. This script issues one, signed by the issuer key, that
carries the claims a step declares: the principal, the actor kind, the scope, the purpose, and for
the reader the registry_record_status claim its row boundary compares. Base Registry Engine (BReg)
refuses a token whose registry_actor_kind claim is missing or is not exactly human, agent, or
service; these tokens
authenticate automated journeys, so the script marks both of them service. Then it issues the
operator’s and the reader’s tokens:
cat > tutorial-work/candidate/issue-token.sh <<'EOF'#!/usr/bin/env bash# issue-token.sh <principal> <scope> <purpose> <output-file> [<extra-claims-json>]set -euo pipefailb64url() { openssl base64 -A | tr '+/' '-_' | tr -d '='; }now=$(date +%s)header=$(printf '{"alg":"EdDSA","kid":"candidate-issuer","typ":"at+jwt"}' | b64url)claims=$(printf '{"iss":"https://issuer.example.invalid","aud":"generic-registry","client_id":"generic-registry-client","sub":"%s","registry_principal":"%s","registry_actor_kind":"service","scope":"%s","registry_purpose":"%s","iat":%s,"exp":%s%s}' \ "$1" "$1" "$2" "$3" "$now" "$((now + 3600))" "${5:+,$5}" | b64url)signing_input=$(mktemp)printf '%s.%s' "$header" "$claims" > "$signing_input"signature=$(openssl pkeyutl -sign -rawin -inkey "$(dirname "$0")/issuer.pem" -in "$signing_input" | b64url)rm -f "$signing_input"umask 077printf '%s.%s.%s' "$header" "$claims" "$signature" > "$4"EOFchmod 700 tutorial-work/candidate/issue-token.shtutorial-work/candidate/issue-token.sh generic-registry-operator registry:generic:operate registry-operations tutorial-work/candidate/secrets/operator-tokentutorial-work/candidate/issue-token.sh generic-registry-reader registry:generic:read registry-reporting tutorial-work/candidate/secrets/reader-token '"registry_record_status":"active"'The credentials file binds one token to every journey step. A token whose claims differ from the step’s declared claims fails the run; the test compares them exactly rather than trusting the label on the binding.
cat > tutorial-work/candidate/credentials.yaml <<'EOF'apiVersion: registry.registrystack.org/breg-schema-test-credentials/v1kind: SchemaTestCredentialsbindings: - journeyId: record-lifecycle stepId: create-record-group credential: type: bearer tokenRef: secret:file/operator-token - journeyId: record-lifecycle stepId: create-record credential: type: bearer tokenRef: secret:file/operator-token - journeyId: record-lifecycle stepId: get-record credential: type: bearer tokenRef: secret:file/operator-token - journeyId: record-lifecycle stepId: read-record-within-the-claim credential: type: bearer tokenRef: secret:file/reader-token - journeyId: record-lifecycle stepId: retire-record credential: type: bearer tokenRef: secret:file/operator-token - journeyId: record-lifecycle stepId: read-record-outside-the-claim credential: type: bearer tokenRef: secret:file/reader-token - journeyId: record-lifecycle stepId: list-records credential: type: bearer tokenRef: secret:file/operator-tokenEOFRun the journeys
Section titled “Run the journeys”SSL_CERT_FILE="$PWD/tutorial-work/candidate/postgres/ca.pem" bregctl test tutorial-work/candidate-project \ --database-id generic-registry-db-1 \ --runtime-config "$PWD/tutorial-work/candidate/runtime-test.yaml" \ --credentials "$PWD/tutorial-work/candidate/credentials.yaml" \ --output "$PWD/tutorial-work/candidate/test-receipt.json" \ --signature-threshold 1 --signature-key-id candidate-signerFixture run passed. 1 journey. profile production package revision sha256:d2886ea2006498adbefd77479e4a40186eada60dbaeeee572d6edeaacee6c3dc schema fingerprint sha256:d459608ae9792a4bc387ff3965652ead7a7a1ac2ffa11b81506b55c66ebc6c02 signing input sha256 248a359554982daa7e0ee627027acda85394b9c3154facbf60c821c6596952ee successful journeys record-lifecycle receipt sha256 c8e518182c09c45c620163d2fe5b71d1cbcaae7581356c15944e000aff96c9cf receipt bytes 1054SSL_CERT_FILE points the TLS client at your certificate authority, and the URL host localhost
must match the certificate’s name. The two signature options declare the signing policy the package
will carry, one signature from the key id candidate-signer, and the receipt records that policy
together with the package revision, the schema fingerprint, and the journey that passed. package
requires this receipt.
Package and sign
Section titled “Package and sign”Build into a new directory. The first run leaves it unsigned and the signing run publishes into the
same directory; after that, package refuses it with package.output.refused, so the next candidate
needs a build-2 of its own.
bregctl package tutorial-work/candidate-project \ --database-id generic-registry-db-1 \ --test-receipt "$PWD/tutorial-work/candidate/test-receipt.json" \ --output "$PWD/tutorial-work/candidate/build-1" \ --signature-threshold 1 --signature-key-id candidate-signerSealed a deployment package. It is awaiting signatures. profile production state awaiting_signatures package revision sha256:d2886ea2006498adbefd77479e4a40186eada60dbaeeee572d6edeaacee6c3dc signature threshold 1 provided signatures 0 package files 18 signing input sha256 248a359554982daa7e0ee627027acda85394b9c3154facbf60c821c6596952ee signing input bytes 14921Nothing is published yet. The output directory holds signing-input.json, the exact bytes to sign,
and a copy of the receipt. Sign the input with the package signer, hand the signature back as hex,
and run the same command again with --signatures:
openssl pkeyutl -sign -rawin -inkey tutorial-work/candidate/package-signer.pem -in tutorial-work/candidate/build-1/signing-input.json -out tutorial-work/candidate/signature.binsignature_hex=$(od -An -v -tx1 tutorial-work/candidate/signature.bin | tr -d ' \n')printf '{"signatures":[{"keyId":"candidate-signer","signatureHex":"%s"}]}' "$signature_hex" > tutorial-work/candidate/signatures.jsonbregctl package tutorial-work/candidate-project \ --database-id generic-registry-db-1 \ --test-receipt "$PWD/tutorial-work/candidate/test-receipt.json" \ --output "$PWD/tutorial-work/candidate/build-1" \ --signature-threshold 1 --signature-key-id candidate-signer \ --signatures "$PWD/tutorial-work/candidate/signatures.json"The report repeats, its lead sentence now reading Sealed and published a deployment package., with state at published and provided signatures at 1. The package is in
tutorial-work/candidate/build-1/package: the effective model, the DDL and migration plan, the
OpenAPI document and JSON Schemas, the inventories, the manifest projection, the journeys, and the
project source it was built from. Its revision binds all of them together with the environment, the
instance, the database id, and the signing policy; the runtime configuration names it next.
Verify the package
Section titled “Verify the package”The runtime accepts a package only through a trust anchor: the signer’s public key, bound to the
same environment, instance, and database id, with the same threshold and key id as the package’s
signing policy. The runtime parses the anchor as canonical JSON, so write it with sorted keys, no
whitespace, and no trailing newline. printf does that; an editor usually does not.
signer_x=$(openssl pkey -in tutorial-work/candidate/package-signer.pem -pubout -outform DER | tail -c 32 | openssl base64 -A | tr '+/' '-_' | tr -d '=')printf '{"apiVersion":"registry.registrystack.org/package-trust/v1","databaseId":"generic-registry-db-1","environment":"development","instanceId":"generic-registry-1","keys":[{"jwk":{"alg":"EdDSA","crv":"Ed25519","kid":"candidate-signer","kty":"OKP","x":"%s"},"keyId":"candidate-signer"}],"threshold":1}' "$signer_x" > tutorial-work/candidate/package-trust-anchor.jsonCopy runtime-test.yaml to runtime.yaml:
cp tutorial-work/candidate/runtime-test.yaml tutorial-work/candidate/runtime.yamlChange three values in the copy. databaseInitializationEnvironment becomes development, the
environment the package was built for; package.root becomes the published package directory;
activeRevision becomes the package revision that package printed:
identity: databaseInitializationEnvironment: developmentpackage: root: <working-directory>/tutorial-work/candidate/build-1/package activeRevision: <package-revision>bregctl verify --runtime-config "$PWD/tutorial-work/candidate/runtime.yaml"Verified the package against the runtime it is bound to. assurance runtime_bound package revision sha256:d2886ea2006498adbefd77479e4a40186eada60dbaeeee572d6edeaacee6c3dc registry id generic-registry registry version 0.1.0 registry revision sha256:47bb7234a81ecd44fef51323c6d92f3291282b5faebb6645a9096f16b9059d5a modules 1 entities 2 routes 8 access entries 8 queries 4 event deliveries 0 DDL statements 23 generated artifacts 14runtime_bound means the check ran with everything the runtime has at startup: the trust anchor,
the identity bindings, the closure of files, and a re-derivation of every generated artifact from the
package’s own source. Keeping local in runtime.yaml is refused: a runtime that initialises a
disposable database has no trust anchor, and the package’s environment must equal the one the
runtime declares.
Clean up
Section titled “Clean up”docker rm -f breg-candidate-postgresrm -f tutorial-work/candidate/package-signer.pem tutorial-work/candidate/issuer.pem tutorial-work/candidate/postgres/postgres.envrm -rf tutorial-work/candidate/secretsdocker rm -f deletes the container and the test database inside it, which is what a disposable
database is for. Deleting the signer key means this package can never be re-signed; a real
candidate’s key lives in your key management, never on a laptop. Keep build-1, runtime.yaml, and
the trust anchor if you continue with Deploy a registry.
What you built
Section titled “What you built”A project reviewed under the production profile; a schema-test receipt from its journeys, run against
PostgreSQL 17 over TLS with tokens from a static JWKS; a package whose revision binds its files,
environment, instance, database id, and signing policy; one Ed25519 signature over its signing input;
and a trust anchor under which bregctl verify accepts the package with runtime_bound assurance.
No registry served a request: the test drove the journeys in-process, and the database was
disposable.
Troubleshooting
Section titled “Troubleshooting”| Symptom | Cause and next move |
|---|---|
bregctl test reports test.database.unavailable | The database already holds managed objects from an earlier run, or the TLS connection failed; the message is the same for both. Rerun the database block, and confirm that SSL_CERT_FILE names ca.pem and that the URLs use localhost, the name on the certificate. |
bregctl test reports test.output.refused | A receipt already exists at --output. The command never overwrites one, so delete test-receipt.json or name a new file before the rerun. |
bregctl test reports test.step.failed and the fixture authority reference was refused | The token bound to that step does not carry the claims the step declares, or it has expired. The message names the step by index, journeys[0].steps[3]. Check the binding in credentials.yaml, and reissue both tokens with issue-token.sh if the run started more than an hour after you issued them. |
bregctl verify reports verify.package.integrity_refused right after a successful package | The trust anchor is not canonical JSON: pretty-printed, keys out of order, or a trailing newline. Rewrite it with the printf command. |
verify.package.binding_refused | activeRevision, identity, or databaseInitializationEnvironment in runtime.yaml does not match the package manifest. local is refused for a published package. |
verify.package.signature_refused | The anchor’s threshold or keyId differs from the policy the package was built with, or the signature covers different bytes. Sign signing-input.json unchanged. |
openssl rejects -rawin or -algorithm ED25519 | The openssl on PATH is LibreSSL. Install OpenSSL 3 and put it first on PATH. |
- Deploy a registry: provision PostgreSQL for real, write the runtime configuration, activate the package with
apply, and serve it withbreg. - Test with journeys to extend the journeys before the next candidate.
- Control access per profile to close the finding with a row boundary.