Skip to content

Releases: cboxdk/cbox-id

v0.35.0 — a console that explains itself

Choose a tag to compare

@sylvesterdamgaard sylvesterdamgaard released this 01 Aug 10:18
8651f1c

The console assumed you already knew what an IdP does. This release stops it assuming
that — and fixes the CI that had been red on main for a reason no developer machine
could reproduce.

Onboarding and help

  • A "?" on every page title opens two or three sentences in plain language, and
    links to a full guide where one exists. Twelve new admin guides ship under
    docs/guides/. Nothing links to a page that does not exist: a topic with no guide,
    or a deployment with DOCS_BASE_URL blanked, renders no link at all.
  • The setup checklist is measured, not asserted. Every step is derived from live
    state, so it ticks itself whether the work was done from the checklist, the page, or
    the API. The old card hardcoded "Organization created ✓" and had three items that
    could never tick. Steps an organization is not entitled to are dropped rather than
    shown locked, so the list can actually reach 100%.
  • A guided first run at /get-started, with the same measured steps given room to
    explain themselves. Reachable from Settings after it is dismissed.
  • Empty states teach instead of shrugging: what the thing is, why you want it, and
    the first three steps.

Navigation that means what it says

  • Nav label == page heading == browser title. Six pages disagreed with the sidebar
    entry that reached them ("Stored tokens" → a page headed "Token vault"). The SCIM pair
    became Sync users in / Sync users out, which says which way people move.
  • One rail area per subject. The console had grown twelve top-level areas because
    each module minted its own; analytics, compliance and risk now join the area that
    already owns their subject. Area orders are unique, so the rail no longer reshuffles
    depending on which modules are enabled.
  • The activity log names people. Actor and target were raw ULIDs, so the row saying
    a member was added did not say who.

Breaking

  • One env prefix, not two. The ID_* fallback kept alongside CBOX_ID_* in 0.34.0
    is removed — 37 config reads carried both spellings. See Upgrading below.

Fixed

  • CI, red on main and unreproducible anywhere else: the suite needed a Redis nobody
    starts.
    cboxdk/laravel-queue-metrics ships enabled with a Redis storage driver and
    a listener that writes on every job dispatch. Developer machines usually have a Redis
    running, and CI does not — so the suite looked green everywhere except the one place
    that mattered. Metrics are now off for the suite, which is verified with no Redis
    reachable at all. The test environment was also made authoritative while proving it:
    tests/bootstrap.php beats anything the host exports, because phpunit.xml's <env>
    does not write $_SERVER and Laravel reads that first.
  • The parallel suite. 48 tests failed under --parallel while passing serially:
    helpers declared in one test file and called from another are simply absent in the
    worker that needs them. Shared fixtures moved to tests/Pest.php, with a tokenising
    guard so the next leak fails the build.
  • The accessibility guard grew from 8 console pages to 18 — all WCAG 2.1 A/AA clean.

Upgrading

Breaking: CBOX_ID_ is now the only env prefix. 0.34.0 renamed the vendored
modules' variables and kept the old ID_* names as a fallback; that fallback is gone.
An ID_* variable is now ignored rather than refused, so the setting silently falls
back to its default — CBOX_ID_DEVICES_ENABLED to off, CBOX_ID_COMPLIANCE_SINK
to null. Grep your deployment before upgrading:

grep -rn 'ID_[A-Z]' k8s/ .env | grep -v CBOX_ID_

UPGRADING.md lists the four
affected families.

php artisan migrate — one new table (onboarding_dismissals), holding the only thing
the checklist cannot derive: an admin having put it away.

Optional: point DOCS_BASE_URL at a rendered docs site (and clear DOCS_LINK_SUFFIX)
once one exists, or blank it on an air-gapped deployment to drop every outbound link.

v0.32.0 — one open app

Choose a tag to compare

@sylvesterdamgaard sylvesterdamgaard released this 27 Jul 16:06

The open-core split is gone. Cbox ID self-hosts with no licence key, no limits, and every capability present; billing becomes something you add where you actually bill someone.

Added — the five console modules (analytics, compliance, connectors, risk-plus, whitelabel) are part of the application; CBOX_ID_ENTITLEMENTS=open is the new default; a relational analytics event store for deployments with no column store; CI runs MySQL 8.4 and PostgreSQL 17.

Fixed — four defects the new engine coverage surfaced, two of them live bugs on an engine that ran in production: the Apps & API keys page 500ing on MySQL, and the environment-scope guard refusing legitimate writes on PostgreSQL because thirteen ulid() columns compiled to blank-padded char(26).

Operators upgrading: read UPGRADING.md — a metered deployment must now say so explicitly, and the five plugin packages must be removed.

v0.31.0 — account lifecycle audits itself

Choose a tag to compare

@sylvesterdamgaard sylvesterdamgaard released this 27 Jul 12:07

Picks up laravel-id v0.64.0, where Accounts::suspend() and reactivate() take an actor and record the audit internally. The operator screen's own audit block is gone — an audit written at the call site is one a second caller can forget, and this screen was the only caller there had ever been.

Known cost, tracked rather than hidden: the package scopes that entry to the system chain (an account sits above the tenancy boundary, consistent with PlatformOperators), so an account's own activity view no longer shows its suspension. The test now asserts both halves so the gap cannot quietly disappear.

v0.30.0 — token privilege fixes

Choose a tag to compare

@sylvesterdamgaard sylvesterdamgaard released this 27 Jul 10:11

Picks up laravel-id v0.63.0: /user-tokens/introspect required no scope (any environment API key could introspect every PAT in its environment), and an empty resource-family allow-list granted every family.

Both are breaking upstream and both have zero blast radius here — verified against the production database, which holds 0 environment API keys and 0 user API tokens, and this app calls neither changed contract.

Also carries a table rename (password_reset_tokenscbox_id_password_reset_tokens, which collided with Laravel's own skeleton migration). Requires php artisan migrate — on PostgreSQL a rename, not a rewrite.

v0.29.0 — identifier columns off padded char

Choose a tag to compare

@sylvesterdamgaard sylvesterdamgaard released this 27 Jul 06:49

Picks up laravel-id v0.62.0, which moves 225 identifier columns across 81 tables off PostgreSQL's blank-padded char. Requires php artisan migrate.

This deployment is not currently affected — every id in production is a full 26-character ULID, verified directly — so the migration is preventive and changes no data. On PostgreSQL foreign keys are preserved throughout, so there is no integrity window.

The reason it matters: the framework's test suite had never run against PostgreSQL, the engine this app runs on. It now does, 1359 passed / 0 failed, up from 1020 / 338 failed.

v0.28.0 — honest destructive actions, hardened API

Choose a tag to compare

@sylvesterdamgaard sylvesterdamgaard released this 27 Jul 03:56

Security: a "deleted" organization kept authenticating its members, consenting, and minting tokens — the status was written by the console and enforced by nothing. Latent rather than breached (production had none in that state). Now enforced through an exhaustive match with no default, so a status added upstream fails analysis rather than defaulting to allowed.

The console claimed to delete users and did not. There is no foreign key on user_id anywhere, so the guard clause meant to catch leftover records was unreachable — every "User deleted" left the person's sessions, passkeys, TOTP seeds and full SCIM payload behind, with no event and no audit entry. Removed rather than repaired; deactivation is the honest capability today.

Compliance: the docs inherited a GDPR Art. 17 claim with no implementation behind it. Corrected.

API hardening before the Terraform/CLI expansion: the documented error envelope is now actually honoured (validation failures rendered Laravel's default shape, with no error key that both specs declare required), rate limits bucket per credential instead of per IP (one NAT meant one shared bucket across tenants), and every API response in the suite is validated against its documented schema — which found eight routinely-null fields typed as non-nullable, plus a documented field the API has never returned.

Operators can now suspend accounts.

v0.27.0 — the risk trail

Choose a tag to compare

@sylvesterdamgaard sylvesterdamgaard released this 27 Jul 01:32

Monitor mode has been running for weeks and producing nothing: every risk decision went to Log::info, production runs LOG_CHANNEL=stderr with no aggregation, and each rollout destroyed the corpus. There was no way to set an enforcement threshold from evidence.

Risk decisions now persist to a risk_decisions table with the score, outcome, the mode the decision was made under, and the per-signal weighted points — so a signal can be re-weighted, not just the threshold moved. docs/security/adaptive-risk.md carries five tuning queries, each executed against real PostgreSQL.

Deliberately not the audit chain: those appends lock the chain head, and these writes happen before authentication, so an unauthenticated burst could serialise the tamper-evident trail of everyone who is authenticated.

IP and email are both keyed HMAC pseudonyms; the mail domain stays clear for provider-abuse patterns. 90-day retention. RISK_MODE stays monitor — enforcement is the decision this data exists to inform.

v0.26.0 — all six hook points in the console

Choose a tag to compare

@sylvesterdamgaard sylvesterdamgaard released this 27 Jul 00:07

Requires cboxdk/laravel-id ^0.59.1.

Added

  • Five more inline hook points are now registrable from the console — post_login,
    pre_registration, post_registration, pre_password_change,
    post_password_change, alongside the existing token_minting. The console reads
    HookPoint::cases(), so they appeared as soon as the framework shipped them.

Fixed

  • The hook picker rendered the raw PHP case name, so an admin chose between
    "TokenMinting" and "PrePasswordChange", and the list showed the machine value
    token_minting. Both now use the framework's label(), and the picker shows
    description() beside each option — which states whether that hook can refuse the
    operation, the fact you need before wiring a URL that can stop people signing in.

v0.25.0 — signup abuse controls

Choose a tag to compare

@sylvesterdamgaard sylvesterdamgaard released this 26 Jul 23:37

Signup had no bot protection, and production showed the bill: five of eight accounts
were Gmail dot-abuse bot signups, each of which had provisioned a full environment and
none of which ever verified an address.

Security

  • A self-serve signup no longer provisions an environment up front. The account, its
    home organization, its owner and its first project are created immediately; the
    environment — the routable IdP whose signing key is warmed on creation — is released
    only when the owner opens the emailed verification link (new
    App\Platform\SignupProvisioner, consumed by EmailVerificationController). This does
    not make bulk signup harder, it makes it worthless. Idempotent: a replayed link never
    mints a second environment, and a suspended account is not revived by one.
  • A risk-triggered CAPTCHA on signup. An Outcome::Challenge / StepUp from the
    risk scorer now demands a Cloudflare Turnstile token, verified server-side against
    siteverify (new App\Platform\Turnstile); previously the signup form consulted only
    shouldBlock(), so a Challenge outcome did nothing at all. A missing or rejected token
    is a field error, never a 500. Deliberately not an always-on CAPTCHA — the friction
    lands on the submissions the scorer flagged.
  • Both keys unset (CBOX_ID_TURNSTILE_SITE_KEY / CBOX_ID_TURNSTILE_SECRET_KEY) means
    the feature does not exist: no widget, no Cloudflare script, and the CSP keeps its
    strict script-src 'self' 'unsafe-eval'. https://challenges.cloudflare.com is added
    to script-src and frame-src only on a deployment that configured it.

Changed

  • risk.mode is untouched and still ships as monitor. Flipping it to enforce is a
    production tuning decision that needs the monitor-mode data behind it; this release
    only makes the Challenge branch mean something once it is flipped.
  • The workspace launchpad explains the wait, rather than showing a project with no
    environments and no reason why. It now also names the address the confirmation went to
    and the sender to look for, and says the link is good for 24 hours.

Added

  • A resend control on the launchpad banner (App\Platform\MemberEmailVerification,
    App\Platform\Enums\VerificationResendOutcome). Holding the environment back until the
    address is proven put a real owner's whole account behind one email; without a resend,
    losing it or letting the 24-hour token lapse left an account with a member, a project
    and no way forward. The action takes an AccountMember, never an address, so there is
    no input to steer it at someone else's inbox; it retires every previously-issued link
    before minting the next one (single-use is not the same as single-live); it answers
    identically whether or not the address is already confirmed, so the button is not a
    verification oracle; and it is a no-op once the environment is up. Throttled at 3 per
    10 minutes per member
    — outbound mail is the abusable resource, and the member id is
    the key nobody can rotate.

v0.24.0 — MembershipRole adoption

Choose a tag to compare

@sylvesterdamgaard sylvesterdamgaard released this 26 Jul 07:36

Requires cboxdk/laravel-id ^0.58, which made MembershipRole a type rather than a
string on the membership and invitation contracts. Adopting it surfaced four latent
bugs the type change had nothing to do with.

Fixed

  • Any page listing pending invitations would have 500'd. Invitation::$role is now
    cast to the enum upstream, and two templates still called ucfirst() on it —
    environment/organizations/show.blade.php and members.blade.php. Neither had a test.
  • The role dropdowns never pre-selected a member's current role, because
    @selected($m['role'] === $val) compared an enum case to a string. Pre-existing.
  • Role validation is now deny-by-default against the assignable set rather than merely
    a valid enum case. MembershipRole::tryFrom('viewer') returns a real case that this
    console must never assign, so a non-null check was not sufficient — 'viewer' is now
    refused exactly like 'archduke'.
  • The three JS-invoked role actions had no field to report into and previously used
    in_array(); they now go through the same deny-by-default parse.

Changed

  • New App\Platform\OrgRoles — the subject-plane analogue of the framework's
    AccountRole::assignable(), exposing the assignable set, a validation rule, a message
    naming the accepted roles, and a parse for actions with no field. All four role selects
    now render from it instead of hand-written option triples, and each gained an @error
    sink so the message is visible.
  • cboxdk/laravel-id floor raised to >=0.58 — the app does not run below it.

Testing

  • Four regression tests drive each affected form with both an unknown value and a
    real-but-unassignable enum case, asserting a field error rather than a 500, no side
    effect, and no mail. Mutation-checked: swapping the guard back to from() makes them
    fail with the ValueError at the component line, so they pin the distinction rather
    than the presence of a rule.