PermissionSync is an architecture-first service for synchronizing a user's desired permissions with a selected target. The active constraints are recorded in the ADR index.
The Rust workspace contains target-neutral Core domain contracts, deterministic
runtime target routing, a concrete Generic REST Permission Provider,
selected-target synchronization orchestration, an internal permissionsync-auth
boundary for technical-caller JWT verification and scope target selection,
framework-neutral inbound HTTP request processing, a concrete GLPI Target
Adapter implementing the selected GLPI V1 reconciliation contract, typed runtime
configuration with deterministic application composition, and the executable
service runtime described below: YAML configuration delivery, an Axum listener,
bounded inbound admission and synchronization capacity, health, readiness and
metrics endpoints, graceful shutdown, structured logging, Prometheus metrics,
and optional OTLP trace export.
It is packaged as a minimal, non-root production OCI image, with a reference Kubernetes deployment contract and a real supported-Keycloak contract suite, both described below.
The executable loads exactly one UTF-8 YAML configuration document. Its path
comes only from the required PERMISSIONSYNC_CONFIG_FILE environment variable:
PERMISSIONSYNC_CONFIG_FILE=/etc/permissionsync/permissionsync.yaml permissionsyncThere are no layered configuration files, command-line overrides, per-value environment overrides, environment substitution inside the document, include or merge mechanisms, or runtime reload. Comments are allowed and carry no semantics. Unknown or misspelled fields are rejected rather than ignored.
A documented example with placeholder values only is in docs/permissionsync.example.yaml. Secrets and private trust material are supplied inline through that external file and are never built into the binary or image.
Every duration is an integer number of whole milliseconds, spelled with a
_milliseconds field suffix. These top-level sections are required:
| Section | Contents |
|---|---|
listener |
address and a non-zero port |
request |
Overall deadline, inbound admission limit, synchronization capacity |
shutdown |
Grace period |
authentication |
Issuer, audience, algorithm allowlist, trusted source, cache policy, metadata timeout, clock skew, optional trust anchors |
observability |
Bounded log_level, optional tracing |
targets |
Logical target to adapter identifier routes |
The provider and glpi sections are optional. The Provider selects one
supported implementation (generic_rest), and glpi configures the one
process-wide GLPI backend.
Startup aborts for a global defect: an unreadable, non-UTF-8, empty, multi-document, or malformed file; an unknown top-level field; an invalid listener, deadline, admission limit, or capacity; an admission limit or synchronization capacity whose bounded-concurrency primitives could not be constructed; a synchronization capacity above the product ceiling of 1024; a shutdown grace below the overall deadline; invalid authentication configuration; invalid tracing configuration while export is explicitly enabled; a duplicate or grammar-invalid logical target; or a listener that cannot be bound.
Startup does not abort because a component is unusable. An absent or invalid
provider section leaves the Provider unavailable, and an absent or invalid
glpi section leaves every configured route selecting glpi recognized but
unavailable. Unrelated correctly configured routes stay serviceable, targetless
requests keep working, and a temporarily unreachable Keycloak, Provider, GLPI, or
telemetry backend never prevents startup.
All four paths are served on the single configured listener:
| Path | Method | Behaviour |
|---|---|---|
/api/sync-user |
POST |
The synchronization contract, with an empty response body |
/healthz |
GET |
200 while the process and HTTP runtime are functioning |
/readyz |
GET |
200 only while the authenticator has usable trusted verifier state, otherwise 503 |
/metrics |
GET |
Prometheus text exposition |
Readiness means that PermissionSync can currently verify technical callers safely. It does not require current Keycloak connectivity while still-usable cached verification material exists, and it never depends on the Provider, GLPI, a target, or a telemetry backend. It turns false as soon as shutdown begins.
The three operational endpoints take neither an inbound admission permit nor a place in the admission wait list, and connections are not gated behind either, so a saturated synchronization workload cannot keep them from being answered. They expose no secrets, URLs, targets, credentials, trust material, JWT details, or internal errors, and there is no general status or configuration endpoint.
inbound_admission_limit bounds both how many synchronization requests may be
admitted at once and how many more may be parked waiting, and only admitted
requests buffer a body. A parked request is woken as soon as a permit frees. A
request arriving when both bounds are full is refused at the transport boundary
and receives no PermissionSync response, so saturation adds no 429, 503, or
other caller-facing status; such refusals are counted by
permissionsync_inbound_admission_refused_total.
Two further bounds are fixed product values rather than deployment knobs: a one-mebibyte inbound body limit, and a thirty-second window in which a client must deliver one complete request head.
The number of simultaneously accepted connections is not bounded inside the process; a deployment that must bound it should enforce a connection limit in front of PermissionSync. Descriptor exhaustion is retried behind a short backoff rather than being fatal.
Concurrent synchronization requests are allowed, including requests for the same
user on the same target, and PermissionSync assigns them no ordering:
synchronization_capacity bounds resource use, not exclusion. A caller that
needs a settled outcome for one user must sequence its own deliveries. See
ADR-0003,
ADR-0007, and
ADR-0009 for why.
Ordinary runtime events are structured JSON on standard output; startup and fatal diagnostics go to standard error as fixed, value-free categories. Configuration selects only the bounded log-level threshold.
Prometheus metrics are always available at /metrics and use only closed,
low-cardinality label values. Trace export is an additional optional channel,
disabled unless observability.tracing.enabled is true. When enabled, spans
are exported over OTLP/HTTP with protobuf encoding to one absolute HTTPS
endpoint, using mandatory certificate and hostname validation, refusing every
redirect, and sending configured exporter credentials only to that endpoint's
origin. Export is asynchronous, batched, and bounded: an unavailable, slow, or
failing backend drops telemetry instead of affecting a synchronization outcome,
readiness, or capacity. Inbound W3C traceparent and tracestate are accepted
as transport metadata only; a malformed value counts as missing telemetry
context and never changes an outcome. No trace-context header is added to
Provider or Target Adapter requests.
A per-connection, network, or resource-pressure accept failure is retried and never terminates the process; only repeated consecutive failures that indicate an unusable listener are fatal, and the process then shuts down as below and exits reporting failure.
On SIGTERM or SIGINT the process marks readiness false, stops accepting,
admits no further synchronization request and releases pending admission waits,
lets already admitted requests finish within the configured grace period, then
cancels the remaining request contexts, terminates and awaits its remaining
tasks, and performs a bounded trace flush when tracing is enabled. Final
telemetry may be lost if the exporter is still unavailable.
PermissionSync ships as one generic, immutable, versioned OCI image. It embeds no deployment URL, credential, permission data, target instance, or environment trust: the image is identical for every deployment, and everything specific to one is runtime configuration.
Build it locally from the repository root:
docker build --file Dockerfile --tag permissionsync:local .The Dockerfile has two stages. The builder is the official Rust image, pinned by tag and digest, and it installs the toolchain rust-toolchain.toml selects. The runtime stage is a distroless base holding the executable plus the C runtime, OpenSSL, and system trust store it links against — no shell, package manager, compiler, Cargo state, source tree, test material, configuration, or trust material.
Run it with the configuration mounted from outside the image:
docker run --rm \
--read-only --cap-drop=ALL --security-opt=no-new-privileges \
--publish 127.0.0.1:8443:8443 \
--env PERMISSIONSYNC_CONFIG_FILE=/etc/permissionsync/permissionsync.yaml \
--volume /etc/permissionsync/permissionsync.yaml:/etc/permissionsync/permissionsync.yaml:ro \
permissionsync:local| Property | Behaviour |
|---|---|
| Entrypoint | /usr/local/bin/permissionsync, exec form, so it is PID 1 and receives SIGTERM/SIGINT directly |
| User | 65532:65532, a dedicated unprivileged account declared numerically |
| Configuration | Required externally through PERMISSIONSYNC_CONFIG_FILE; the image sets no environment value at all |
| Listener | Whatever listener.address and listener.port select; no port is baked in, so there is no EXPOSE |
| Filesystem | Nothing is written, so a read-only root filesystem needs no writable path, not even /tmp |
| Private CA | Only through additional_trust_anchors_pem in the mounted configuration |
| Health check | None in the image: Kubernetes is the supported platform and owns the probes below |
| Platform | linux/amd64, the only platform published and the only one validated in CI |
The image carries the standard org.opencontainers.image.* title,
description, source, url, documentation, licenses, and vendor labels. Version,
revision, and creation labels are added by the publishing workflow, so a local
build claims no release identity.
Releases are published to ghcr.io/neteye-platform/permissionsync from stable
vX.Y.Z tags only. Each release publishes the version tag and also moves the
conventional latest tag.
| Reference | Mutability |
|---|---|
…/permissionsync:1.2.3@sha256:… |
Immutable by construction. The reproducible deployment reference. |
…/permissionsync:1.2.3 |
Immutable by policy: the release workflow refuses to publish a version that is already published. |
…/permissionsync:latest |
Mutable by design. It moves to each new stable release. |
The version tag and the content digest are the release identity. latest is a
convenience and discovery reference only and is never part of deployment
correctness, so a deployment that needs reproducibility or offline recovery
MUST reference the immutable digest, which the publishing workflow prints for
every release. Do not pin latest.
Normal restart and recovery must not depend on public registry connectivity. Keeping the selected digest available — mirrored, cached, or on offline installation media — is the deployment platform's responsibility; this repository publishes an immutable image and implements no registry or mirror, so GHCR availability is never part of PermissionSync's runtime correctness.
Kubernetes is the only explicitly supported deployment platform, and PermissionSync still depends on no Kubernetes API, object, discovery, or configuration semantics. Manifests and Helm charts are deployment-owned optional artifacts; deploy/kubernetes/permissionsync.example.yaml is a small placeholder-only example of the contract the platform must provide, not a mandatory production architecture and not PermissionSync's configuration API. Copy it, replace every placeholder, and own the result yourself.
The supported deployment contract is:
| Contract | Requirement |
|---|---|
| Configuration | Exactly one external YAML document, mounted read-only |
| Configuration path | PERMISSIONSYNC_CONFIG_FILE pointing at that mounted file, and no other environment value |
| Liveness | GET /healthz on the configured listener port |
| Readiness | GET /readyz on the same port |
| Seccomp | seccompProfile.type: RuntimeDefault |
| User | runAsNonRoot: true with the image's runAsUser/runAsGroup of 65532 |
| Privileges | allowPrivilegeEscalation: false, not privileged, all capabilities dropped |
| Filesystem | readOnlyRootFilesystem: true; no writable path is required |
| Host namespaces | None: no host network, PID, or IPC, and no Kubernetes API token is mounted |
| Termination | SIGTERM, with terminationGracePeriodSeconds covering the whole bounded shutdown horizon |
| Image | The published image pinned by immutable digest, kept available locally for restart |
Any volume source that presents the single document at the mounted path works. Kubernetes Secrets are not required, and PermissionSync neither requires nor understands a secret backend: a Secret, a ConfigMap, a projected volume, a CSI secrets-store volume, or an operator-managed volume are all equally valid. The example shows a Secret only because that document normally carries credentials and private trust material.
terminationGracePeriodSeconds has to cover PermissionSync's whole bounded
shutdown horizon, which is wider than the configured request grace. Once
shutdown.grace_milliseconds expires, the process still spends a fixed bounded
window letting already cancelled requests return, and then a bounded final
trace flush when tracing is enabled, before it exits. Both are fixed product
values of a few seconds, not deployment knobs, so budget:
shutdown.grace + fixed post-grace cancellation window
+ bounded final lifecycle and trace cleanup
+ operational margin
The example manifest keeps 30 seconds against the example configuration's
20-second grace, which leaves ample headroom for both fixed phases. Choosing
this horizon is the deployment's responsibility; PermissionSync keeps its own
shutdown internally bounded either way, and a horizon that is too short simply
means Kubernetes sends SIGKILL before the process finished its own sequence.
Replicas, resource requests and limits, scheduling, Service and Ingress objects, and TLS termination in front of the listener are deployment concerns. The example shows resource bounds as placeholders for that reason.
CI exercises exactly one supported Keycloak release, pinned by release tag and immutable digest in the disposable Keycloak environment. That compose file is the single declaration of the release: the bootstrap and the contract suite both read it from there, so a Renovate update needs no second edit, and such an upgrade must pass the contract suite before it can be merged. That release is the tested deployment baseline, not a claim that other releases are broken: PermissionSync's contract is the OIDC and JWT behaviour recorded in ADR-0002, and any Keycloak that satisfies it works.
Against a real Keycloak over real HTTPS, the suite covers both supported
trusted-source modes (OIDC discovery and a directly configured jwks_uri), the
Client Credentials token and permissionsync:<target> scope contract, the
refusal of tokens with a wrong audience, a foreign realm, an expired lifetime or
a disallowed algorithm, behaviour while the metadata source is unreachable, and
a real signing-key rotation. Each case is named and explained in
real_keycloak.rs.
Certificate and hostname validation stay fully enabled; the disposable CA is
supplied through additional_trust_anchors_pem, and every credential is
generated per run and destroyed with the environment. Adversarial parser,
cryptographic, and cache-timing cases belong to the hermetic auth tests.
Run it locally. The disposable environments drive docker and
docker compose, so they need Docker Engine with the Compose v2 plugin:
source <(crates/permissionsync-auth/integration/keycloak/bootstrap.sh)
set -a
source "$KEYCLOAK_TEST_RUNTIME_ENV"
set +a
cargo test -p permissionsync-auth --test real_keycloak --locked -- --ignored
crates/permissionsync-auth/integration/keycloak/teardown.sh| Layer | What it covers | How to run |
|---|---|---|
| Hermetic workspace tests | Every contract that needs no external service, including the image and Kubernetes contract checks in tests/ |
cargo test --workspace --all-features --locked |
| Real GLPI suite | The production GLPI V1 reconciliation contract against a disposable GLPI | GLPI adapter test workflow |
| Real Keycloak suite | The supported-Keycloak authentication and metadata wire contract | Keycloak workflow |
| OCI and deployment contract | The built production image, its hardening, its signal handling, and the Kubernetes example | OCI workflow |
The OCI workflow checks the built image itself — hardening, metadata, absence
of build and configuration material, startup without configuration, readiness
while the metadata source is unreachable, and graceful SIGTERM — and then runs
that same image against the disposable HTTPS Keycloak. Both checks live in
integration/oci/ and take a built image reference, so they
run locally too, against Docker Engine with the Compose v2 plugin:
docker build --file Dockerfile --tag permissionsync:local .
integration/oci/image-contract.sh permissionsync:localThe Kubernetes example is validated by the organization's
kubeconform hook
configured in .pre-commit-config.yaml, which runs in
prek run --all-files and in the shared pull-request checks.
The exact Rust toolchain is defined in rust-toolchain.toml. Install rustup and run this from the repository root to install the selected compiler and required components:
rustup toolchain installRustup selects that toolchain automatically for Cargo commands run in this repository. Build, check, and test the workspace with:
cargo build --workspace --all-features --locked
cargo check --workspace --all-targets --all-features --locked
cargo test --workspace --all-features --lockedNormal Cargo tests remain hermetic and require neither Docker, GLPI, nor Keycloak. The GLPI adapter has a dedicated disposable real-GLPI integration layer, and authentication has a dedicated disposable real-Keycloak integration layer; CI executes both, plus the container and deployment contract scripts in integration/oci/, through the workflows listed under Testing.
The dependency-policy check requires the exact, Renovate-managed
CARGO_DENY_VERSION in
the Rust validation workflow:
cargo install --locked --version <CARGO_DENY_VERSION> cargo-denyRun the complete local validation suite before opening an implementation PR:
prek run --all-files --refresh
cargo fmt --all -- --check
cargo deny --locked check
cargo check --workspace --all-targets --all-features --locked
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
cargo test --workspace --all-features --locked
RUSTDOCFLAGS="-D warnings" cargo doc --workspace --all-features --no-deps --locked
git diff --checkDirect Cargo dependencies must be necessary for the current change, use exact
versions such as =1.2.3, and update the committed Cargo.lock in the same
change. deny.toml enforces advisory, license, source, wildcard, and duplicate
version policy.
Tests must be deterministic and isolated. They must not rely on public Internet access, production services, timing races, arbitrary sleeps, unseeded randomness, execution order, retained state, fixed ports, or developer-specific configuration. Never retry a failed test; fix the defect or race instead. See AGENTS.md for the full testing policy.