Unreleased documentation. These pages follow the main branch and can change before the next release. For supported guidance, use v0.26.1.
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-projectConfirm 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 an unchanged bregctl init project; if you edit 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-projectcheck succeededrevision: sha256:51370215a8cfcdecb32e91423aca4dcd599e884cb985eb66ba62d5a128d262fefinding access.profile.unrestricted_collection at 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 accessThe generated project ships this finding on purpose: the operator profile lists every record because
one operations team runs the whole registry, and the comment that introduces that profile in
registry.yaml says how to close it (a rowBoundaries entry, or removing list from the grant).
Closing it would also change the journeys, so treat it as reviewed and see what a stricter gate does
with it:
bregctl check --production --deny-findings tutorial-work/candidate-projecterror access.profile.unrestricted_collection at 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 accessThe 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 the PostgreSQL 17 image the quickstart launcher uses, 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 scope, the purpose, and for the reader the
registry_record_status claim its row boundary compares. 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","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-signertest succeededprofile: productionpackage revision: sha256:577c1f7f3d7d5b9af242dc3aa41ce0fa91071909cd71609a821cfe5c88467490schema fingerprint: sha256:d1a9dc914af087a4ab3118ef7bbbc8aad718520362eeeba3e80e7ce9fd93f9cfsigning input sha256: f2a7b47e9002287fd008da91b5c1322b92ca898af250b03da21c7102865daea3successful journeys: record-lifecyclereceipt sha256: d0a2e9ac10cc46ddd903a6b158f8f2df4df0d379f7a74eeecf6144d4f3595032receipt 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-signerpackage succeededprofile: productionstate: awaiting_signaturespackage revision: sha256:577c1f7f3d7d5b9af242dc3aa41ce0fa91071909cd71609a821cfe5c88467490signature threshold: 1provided signatures: 0package files: 18signing input sha256: f2a7b47e9002287fd008da91b5c1322b92ca898af250b03da21c7102865daea3signing input bytes: 14081Nothing 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 with state: published and provided signatures: 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"verify succeededassurance: runtime_boundpackage revision: sha256:577c1f7f3d7d5b9af242dc3aa41ce0fa91071909cd71609a821cfe5c88467490registry id: generic-registryregistry version: 0.1.0registry revision: sha256:51370215a8cfcdecb32e91423aca4dcd599e884cb985eb66ba62d5a128d262femodules: 1entities: 2routes: 7access entries: 7queries: 3event deliveries: 0DDL statements: 22generated 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.