Skip to content

Artifact Store

The Artifact Store is an organization-scoped place to upload typed artifacts — TLS certificates, Keycloak provider JARs, and outbound trust CA certificates — and attach them to your managed app instances. In v1, attaching is available for Managed Keycloak instances only; the store itself is a generic, extensible layer, but which operators can consume an artifact is decided per kind.

Your artifact bytes — including, for a TLS certificate, the private key at upload time — are written to a bucket in your own organization's object storage tenancy, not a shared platform bucket. Capacity is metered the same way as any other object in your org's S3 usage.

Org-admin only

Uploading, listing, deleting, attaching, and detaching artifacts all require the Admin role on the organization. This is a dedicated, non-delegatable grant — it is not part of the ten permission categories/presets described in Roles & RBAC and cannot be handed to a Member account via a custom permission grant. A customer's private key material and the code that runs inside their own identity provider are not a delegable surface.

Known limitations at this release

  • Attaching an artifact to an instance is available for Managed Keycloak only — other managed-app kinds cannot consume the store yet.
  • There is no malware or static-content scanning of uploaded provider_jar files. You are uploading code that runs inside your own Keycloak instance; treat it with the same care you would any other code you deploy.
  • There is no customer-managed rotation policy. Replacing an artifact (e.g. a renewed certificate) is a manual upload + re-attach.
  • GET requests return metadata only — see Private key material is write-only below.

The three kinds

Kind What it's for Upload fields Attaches to
tls_cert A TLS certificate (plus its private key, plus an optional CA chain) to serve one of your app's already-configured custom-domain hosts, instead of the platform's own Let's Encrypt certificate. cert, key, optional chain The app's custom-domain TLS secret
provider_jar A Keycloak SPI/provider JAR to inject into every pod of the instance — for example a custom claims mapper. file The instance's spec.providers[]
ca_cert A CA certificate bundle Keycloak should trust for outbound connections it makes itself — for example an internal LDAPS server signed by a private CA. file The instance's spec.truststore.secretRefs[]

An artifact's name is a DNS-1123 label (lowercase alphanumeric or -, starting and ending alphanumeric, 63 characters max) and must be unique within your organization for that kind — you can reuse the same name across different kinds.

Validation

tls_cert is the only kind validated at upload time. On POST, the platform checks, in order:

  1. cert parses as a valid X.509 certificate.
  2. key parses as a valid RSA private key and its modulus matches the certificate's public key — the key must belong to this certificate.
  3. The certificate has not already expired (not_after is in the future).
  4. If a chain was supplied, it verifies against the certificate — checked against a trust store built only from the chain you uploaded, never your system trust store. This is deliberate: a certificate signed by a private CA (like an internal enterprise CA) is never going to be in a public trust store to begin with.

If any check fails, the upload still creates the artifact row — with status: rejected — rather than silently discarding the attempt, and the 422 response names the specific field that failed (cert, key, or chain). A rejected artifact stays visible in the list so you can see what was tried and why it didn't pass; it cannot be attached to anything.

provider_jar and ca_cert have no content validator beyond the size cap (32MB) and a well-formed kind/name — they always land as status: validated.

SAN vs. host is checked at attach time, not upload time

Upload validation does not check the certificate's Subject Alternative Names against any particular hostname — a tls_cert artifact can be uploaded successfully and attached later to whichever configured host its SANs actually cover. See Attach below.

Lifecycle: upload → attach → check → detach

1. Upload

POST /organizations/{organization_id}/artifacts
Authorization: Bearer <token>
Content-Type: multipart/form-data

kind: tls_cert
name: brunata-prod
cert: <certificate.pem>
key: <private-key.pem>
chain: <ca-chain.pem>    # optional

kind is one of tls_cert, provider_jar, ca_cert. provider_jar and ca_cert uploads carry a single file field instead of cert/key/chain. The total upload is capped at 32MB. sha256 is computed server-side — you never supply it.

Returns 201 Created with the artifact resource (see Response envelope), or 422 with code: tls_cert_invalid if a tls_cert upload failed validation (the artifact id is still returned in errors[0].meta.artifact_id, with status: rejected).

List your organization's artifacts

GET /organizations/{organization_id}/artifacts
Authorization: Bearer <token>

Returns metadata only, ordered by kind then name — see Private key material is write-only.

Every item also carries attached_to, so one call answers both "what do I have" and "what is it doing":

"attached_to": [
  { "app_id": "…", "name": "keycloak-prod", "field": null }
]

It has three values, and the difference matters before you delete anything:

Value Meaning
a non-empty array Those apps carry this artifact right now. Delete is refused with 409.
[] Nothing carries it. Delete will succeed.
null Unknown — the instances could not be read this time. Not the same as "nothing"; try again before deleting.

field is set for tls_cert only (public or admin, the custom-domain field it serves) and null for the other kinds, which are not field-scoped.

2. Attach to an app

POST /apps/{id}/artifacts/{artifact_id}/attachment
Authorization: Bearer <token>
Content-Type: application/json

{}

Behavior is per kind:

  • tls_cert — materializes the app's custom-domain TLS secret from the artifact and marks the app as serving a customer-provided certificate. Refused with 422 san_mismatch if the artifact's SANs don't cover the host configured on the app for the target field. Optionally send { "field": "admin" } to target the app's admin-console host instead of its public host (default: public). Applied live by the ingress layer — no instance restart.
  • provider_jar — appends the artifact to the instance's provider list. Because this is code that runs inside your own Keycloak, it requires an explicit typed confirmation: { "confirm": "attach-code" }. Omitting it (or sending anything else) is refused with 422 confirmation_required. Attaching a provider JAR rolls the instance's pods, roughly 90 seconds. The JAR is fetched by the instance rather than copied into it — see Provider JARs: how the JAR reaches the instance.
  • ca_cert — materializes a trust secret from the artifact and appends it to the instance's outbound trust store.

Returns 204 No Content on success. Attaching an artifact never disturbs entries you (or b'nerd support, on your behalf) added another way — only the one entry belonging to this artifact is ever added, replaced, or removed.

3. Check what's attached

GET /apps/{id}/artifacts
Authorization: Bearer <token>

Answers the question the attach and detach calls don't: what does this instance carry right now?

{
  "metadata": { "organization_id": "…", "project_id": "…", "timestamp": "…", "count": 2 },
  "data": [
    { "artifact_id": "…", "kind": "tls_cert", "name": "idp-2026",
      "sha256": "…", "field": "public", "attached": true, "source": "cluster" },
    { "artifact_id": "…", "kind": "provider_jar", "name": "rbac-claims",
      "sha256": "…", "field": null, "attached": true, "source": "cluster" }
  ]
}

field names the custom-domain field a tls_cert serves (public or admin); it is null for provider_jar and ca_cert, which are not field-scoped. source: "cluster" records that the answer was derived from the instance itself on this request, not read out of a stored list.

Two consequences worth knowing:

  • It reads the live instance, so it is always current — including changes made from another browser tab, by the CLI, or by b'nerd support. Use it to confirm an artifact swap ("attach the new JAR, detach the old one") actually landed, rather than trusting what your own session did.
  • It lists artifacts, and only artifacts. Entries that b'nerd support added for you another way (before the artifact store existed, for example) keep working and are never touched — but they are not artifacts of your organization, so they don't appear here.

If the cluster can't be reached, this returns 503 cluster_unavailable rather than an empty list: "nothing is attached" is only ever reported after an actual check.

4. Detach

DELETE /apps/{id}/artifacts/{artifact_id}/attachment
Authorization: Bearer <token>

Returns 204 No Content. tls_cert detach stops the app from serving that certificate but does not remove the custom domain itself. provider_jar/ca_cert detach removes only this artifact's own entry. Detaching the last provider_jar also removes the instance's <instance>-provider-urls secret — see Provider JARs: how the JAR reaches the instance.

5. Delete

DELETE /organizations/{organization_id}/artifacts/{id}
Authorization: Bearer <token>

Returns 204 No Content, or 409 artifact_referenced if the artifact is still attached to at least one app — detach it first.

What a delete would cost differs by kind, which is why the check exists at all:

  • provider_jar is a live pointer. The instance holds a reference to the stored object and fetches it when its pods start. Delete a still-attached JAR and the instance keeps running — until its next restart, when it can no longer fetch it.
  • tls_cert and ca_cert are copied into cluster secrets at attach time. Deleting the artifact does not disturb a running instance, which is serving its own copy. What is gone is the copy in the store: the private key or CA cannot be attached anywhere again, and cannot be recovered (see Private key material is write-only).

The check is made against your live instances at the moment you ask, not against a stored list. If those instances can't be read, the delete is refused with 503 cluster_unavailable and nothing is removed: deleting the stored object while an instance still points at it would leave that instance unable to start the next time it restarts, so an unanswerable question is never treated as a "no".

Provider JARs: how the JAR reaches the instance

A provider_jar is the only kind that is not copied into the cluster. The other two are: attaching a tls_cert or a ca_cert writes the bytes into a Kubernetes secret, and the instance reads them from there forever after.

A provider JAR stays in your organization's bucket, and the instance fetches it on every pod start. That keeps a JAR of any size out of etcd, and it is why a still-attached JAR you delete from the store breaks the instance's next restart rather than the running one. It also means one extra moving part, which this section exists to make visible rather than mysterious.

What happens on attach:

  1. b'nerd mints a presigned, time-limited download URL for that one object, in that one bucket, signed with your organization's own storage credentials. The URL grants a single GET; it carries no key, and it cannot be pointed at any other object.
  2. That URL is written into a secret named <instance>-provider-urls in the instance's namespace, with one entry per provider JAR, keyed by the artifact name.
  3. Only then is the instance's provider list updated — which is what rolls the pods. The order matters: the restarting pod reads the URL on its way up, so the URL has to exist first.

If step 1 fails, the attach is refused with 503 provider_url_unavailable and your instance's provider list is left exactly as it was. Nothing is half-applied, and retrying is the right response.

While the JAR stays attached, b'nerd re-mints those URLs on a 15-minute cycle, against a 45-minute URL lifetime. This runs for as long as the JAR is attached, not just once at attach time — a pod that restarts for a completely unrelated reason (a node failure, an eviction, a routine rollout) re-runs the same fetch and needs a URL that is valid at that moment. The three-to-one ratio means one missed or delayed renewal still leaves a working URL.

If a renewal fails, the previous URL is kept, not cleared: it is very likely still inside its own window, and clearing it would guarantee the failure that keeping it merely risks. The secret records why, in an annotation, and the timestamp it carries only ever moves on a genuine success — so "we are renewing and it is failing" never disguises itself as "this has just been set up".

When you detach the last provider JAR, the <instance>-provider-urls secret is removed rather than left behind empty. An empty secret carrying a timestamp from a JAR that is no longer attached is a claim about a state that no longer exists, and nothing reads it once the provider list is empty. Detaching one of several JARs leaves the secret in place, minus that one entry.

Nothing to configure

None of this is a setting. It is described here because a JAR that will not load is much easier to diagnose when you know there is a URL involved — see the Init:CreateContainerConfigError and ImagePull/fetch rows in Troubleshooting.

Private key material is write-only

There is no private-key column anywhere in the platform's data model for an artifact — it is genuinely absent, not merely hidden from a response. GET requests (list or show) return metadata only:

id, kind, name, sha256, size_bytes, content_type, status, created_at, updated_at, and — for tls_cert only — subject_cn, sans, not_after, issuer.

Once a tls_cert artifact is uploaded, there is no way to retrieve its private key again through the API or the dashboard. Keep your own copy of the key before uploading if you'll need it elsewhere.

Authorization

All artifact-store operations require the Admin role on the organization:

Operation Requires Token scope
List an org's artifacts Admin role on the artifact's organization artifacts:read (or higher)
Upload / delete an artifact Admin role on the artifact's organization artifacts:write, manage or *
List an app's attached artifacts Admin role on the app's organization artifacts:read (or higher)
Attach / detach an artifact on an app Admin role on the app's organization artifacts:write, manage or *

Unlike most API surfaces, this is not granted through the apps (or any other) permission category — see the callout at the top of this page and Roles & RBAC.

With a static access token

The org-level endpoints (list, upload, delete) also accept a static access token, but only one held by an org admin and carrying an artifacts scope at level *, manage or write:

GET /organizations/{organization_id}/artifacts
X-Access-Token: <token>

A token narrows what its holder can already do — it never widens it, so the same token held by a plain member is refused. artifacts:read grants nothing here: this surface has a single permission covering list, upload and delete rather than a read/write split, so there is no read-only half to hand out.

The app-level endpoints (the attached list, attach, and detach) take the same artifacts scope, so one token covers the whole flow — upload, attach, detach — rather than splitting an operator task across two credentials. artifacts:read is enough to read the attached list; attaching and detaching need *, manage or write.

Endpoints

Method Path Description
GET /organizations/{organization_id}/artifacts List an org's artifacts
POST /organizations/{organization_id}/artifacts Upload an artifact
DELETE /organizations/{organization_id}/artifacts/{id} Delete an artifact
GET /apps/{id}/artifacts List the artifacts an app currently carries
POST /apps/{id}/artifacts/{artifact_id}/attachment Attach an artifact to an app
DELETE /apps/{id}/artifacts/{artifact_id}/attachment Detach an artifact from an app

Troubleshooting

Symptom Likely cause Fix
403 on any artifact-store call Your account has a membership in the org but not the Admin role Only Admins can use the artifact store — ask an org admin to act, or to promote your membership
404 on an org-scoped call Org doesn't exist, or you have no membership in it at all Same shape as every other org-scoped endpoint — check the organization ID and your membership
422 tls_cert_invalid, field key The key doesn't belong to the certificate (modulus mismatch) Re-upload the matching cert/key pair
422 tls_cert_invalid, field cert Certificate has already expired Upload a currently-valid certificate
422 tls_cert_invalid, field chain Supplied chain doesn't verify against the certificate Check you uploaded the correct intermediate/root chain, in order
422 san_mismatch on attach The certificate's SANs don't include the app's configured host for the target field Upload a certificate that covers that exact hostname, or attach with the other field (public/admin)
422 confirmation_required on attach provider_jar attach was sent without confirm: "attach-code" Resend with the exact confirmation string — this is a deliberate typed confirmation, not an accidental omission
409 artifact_referenced on delete One of your instances still carries this artifact — checked live, on this request GET /apps/{id}/artifacts on your instances to find which one, detach it there, then delete
503 cluster_unavailable on delete The instances couldn't be read, so it couldn't be proven that nothing still points at the artifact Nothing was deleted. Retry; if it persists, the cluster itself needs attention — contact support
503 cluster_unavailable on the attached list Same cause, on a read Nothing was changed. Retry
503 cluster_unavailable on attach or detach Same cause, on a write The change may be half-applied — an attach writes the cluster secret before it updates the instance. Retrying is safe (both steps are idempotent) and is the fix
503 provider_url_unavailable on attach A provider_jar's presigned download URL couldn't be minted — your org's object storage was unreachable Nothing was applied: the URL is signed before the instance is touched, so the provider list is unchanged. Retry; if it persists, contact support
422 cluster_rejected on attach or detach The cluster's own API server refused the change and said why — the message quotes it verbatim Not retryable: the request itself was refused, not lost. Read the quoted message; a field is immutable on a TLS secret usually means an object of a different type already exists under that name
Keycloak pod stuck in Init:CreateContainerConfigError, naming <instance>-provider-urls The instance's provider list references a JAR whose download URL secret is missing Detach and re-attach the provider_jar — that rewrites the secret before touching the provider list. If it recurs, contact support
Keycloak init container fails to download the JAR The presigned URL expired before the pod started, or a renewal has been failing URLs are re-minted every 15 minutes with a 45-minute lifetime, so this should self-heal within one cycle. If it does not, contact support — the secret's error annotation records the reason
422 artifacts_unsupported The target app's kind can't hold artifacts — today only Managed Keycloak can Check the app ID; artifacts attach to Keycloak instances
Instance restarted unexpectedly A provider_jar attach/detach happened Expected — provider changes roll the instance's pods (~90s); tls_cert and ca_cert do not

What's next

Task Where
Deploy a Keycloak instance to attach artifacts to Managed Keycloak
Understand the response envelope and error shape Response Envelope
Check whether your account holds the Admin role Roles & RBAC
Look up platform terms Glossary