Skip to content

Security review pack

Verified against v0.10.0 · internal/api, internal/auth, internal/db/migrations, internal/api/custody, cmd/parley/main.go, internal/api/metrics.go, .github/workflows/ci.yml, .github/workflows/release.yml

For anyone signing off on Parley for a team or an organisation. Everything here is also on the individual pages; this is the version you can read in one sitting or print.

A self-hosted planning-poker and daily-standup tool. One Go binary with the frontend compiled in, plus a Postgres database. MIT licensed. No SaaS component, no telemetry, no outbound network calls except to an identity provider you configure.

  • Deployment: a single container image, run on one replica or several.
  • Data: everything is in your Postgres.
  • Identity: none by default on a trusted network; optionally any OpenID Connect provider, or an external authentication proxy for public access.
  • Access control: a six-character passcode per space, space owners (several allowed) for membership and space housekeeping, and org-admin custody over the org’s spaces without reading their contents.

Parley is designed for a team that trusts each other and an operator who does not want an account system. Its space boundary is a shared passcode, not identity — anyone holding a code has participation in that space, and any member can change or remove that code. Inside a session, story mutations and meeting controls are facilitator-only; ordinary members retain voting, their own standup entries, and their own standup readiness signal. The interesting protections are around vote secrecy (strong, and structural) and not disclosing what exists to strangers (also strong). Governance sits beside that: a space can have several owners (membership management and the space itself — not the passcode), and an org admin has custody without access over every space in the org. org_audit_log records a handful of custody and plugin-admin actions in Postgres and is still write-only there — no read API or screen (#399). The same writes, plus sign-in, sign-out, space create and delete, passcode rotate and remove, member add, remove and role change, and guest-link mint and revoke, each emit one structured security event JSON line on stdout. There is still no retention or erasure tooling. /metrics is off by default; when on it is unauthenticated, so it belongs off any public ingress (Observability). The hardened release pipeline publishes an SPDX SBOM and verifiable digest-bound provenance for releases it produces; v0.1.0 through v0.2.1 were not backfilled.

If your requirement is “a team of engineers estimates stories without a third-party SaaS”, it fits well. If your requirement is “attributable, auditable access to work-planning data from a product UI”, it does not fit yet without an SSO proxy in front and a log shipper of your own: security-relevant actions land as JSON on stdout (and custody events also in org_audit_log), and ordinary votes and entries are rows with user_id and timestamps, not a review console.

Data Stored Notes
Display name Yes ≤64 chars, self-chosen or from an IdP claim
Email address No Used only to derive a name at sign-in, then discarded
Password No There is no local password authentication at all
IP address No No column; used transiently for throttling only
Avatar image No Generated from the name
Federated identity Yes Issuer + opaque subject, only in oidc mode
Session tokens Hashed SHA-256; the token itself is never stored
Passcodes Plaintext Deliberate — see below
Session content Yes Story titles, notes, votes, standup entries — free text
Live presence Yes, transiently session_presence holds who has a socket open on which session, on which replica. Deleted on disconnect and swept after ~100s; cascades on user delete

Full detail: Data and privacy.

CapabilityStatusWhat to do instead
Vote secrecy before revealBuilt inEnforced in the serializer, so unrevealed votes are absent from API responses, WebSocket frames and CSV exports alike.
Resource existence not disclosedBuilt inNon-members receive 404 rather than 403 for sessions, including on the WebSocket upgrade.
Cross-site request defenceBuilt inSameSite=Lax, an Origin and Sec-Fetch-Site check rejecting cross-site non-GET, and a JSON content-type requirement, in place of a CSRF token. Note a request with no Origin header, and a sibling subdomain (which browsers label same-site), are not caught by the header check — see the threat model.
Session token handlingBuilt in32 random bytes, SHA-256 at rest, HttpOnly cookie, Secure when BASE_URL is https.
OIDC correctnessBuilt inAuthorization code + PKCE (S256); signature, audience, expiry and nonce verified; state and nonce compared in constant time.
Long-lived session revocationBuilt inWebSockets start a shared-store check every 30 seconds, allow a 30-second timeout, and close with policy code 1008 on failure. Logout does not wait for that interval: it disconnects synchronously on the replica serving it and is fanned out over Postgres NOTIFY to every other replica, which close their sockets for the token on receipt. The periodic check is the fallback if a notification is lost.
Brute-force protection on passcodesBuilt in8 attempts per 60s per address per space, with a confusable-free alphabet and constant-time comparison.
CSV formula-injection escapingBuilt inCells starting = + - @ tab or CR are quote-prefixed.
Container hardeningBuilt indistroless static non-root; the k8s manifest adds drop ALL, no privilege escalation, seccomp RuntimeDefault, read-only root filesystem.
Transport securityNot builtParley never terminates TLS and does not send HSTS. Terminate at a proxy and add HSTS there.
Space owner roleBuilt inmembers.role is owner or member, constrained in the database since migration 0015. A space can have several owners and is never left without at least one. Only an owner may promote, demote, or remove a member, rename or delete the space, or rename or delete a session in it; a member gets 403 and an outsider 404. It stops short of the door: any member may still rotate or remove the passcode, and in-meeting controls — stories, reveals, closing a session — are facilitator-only rather than owner-only.
Org admin custodyBuilt inAn org admin manages every space in the org — including private ones — without reading votes, standups or rosters. On visibility, custody may only narrow (`org` → `private`; `private` → `org` stays the owner's). Custody may also archive or un-archive — un-archive restores org-visible spaces to the directory. Ownership is granted, never transferred; claiming an abandoned space is the one path that becomes access and is audit-logged. See Organizations. There is no instance-wide operator account; cross-instance administration remains SQL.
Audit loggingBuilt inOne stdout JSON security event line per sign-in, sign-out, space create and delete, passcode rotate and remove, member add, remove and role change, guest-link mint and revoke, and every org_audit_log write. Schema: event, actor_user_id, actor_subject, org, space, target, outcome, client_addr, request_id. Cookies, tokens, passcodes and bodies are never logged. The Postgres table remains write-only — no read API or screen (#399). See Observability.
Retention and erasureNot builtApplication records are not automatically deleted. Expired session_tokens rows are the exception: an hourly sweep deletes them. Write erasure SQL against the documented schema; deleting a user who facilitated a session is blocked by a foreign key.
General ingress rate limitingNot builtIdentity creation and room-code guesses are throttled, and resources have configurable caps. Apply request and WebSocket limits at your ingress too. Note the client itself is now a steady low-rate caller: an open space page re-reads the space every 30 seconds, about 2 requests per minute per tab, so size any limit above that.
MetricsBuilt in/metrics is off until METRICS_ENABLED=true, then an unauthenticated Prometheus exposition on the app port. Keep it off a public ingress. See Observability.
Distributed tracingNot builtJSON logs only. Blackbox-probe /readyz and take request metrics from your proxy.
SBOM and provenanceBuilt inReleases produced by the hardened workflow include an SPDX SBOM, maximum build provenance, and a GitHub attestation bound to the final GHCR digest. Releases v0.1.0 through v0.2.1 were not backfilled.
Encryption at restNot builtUse an encrypted volume or your database provider's encryption.
FIPS 140-3 cryptography (800-171 3.13.11)Built inThe -fips image links Go Cryptographic Module v1.0.0 (CMVP certificate #5247) and runs with GODEBUG=fips140=on — not only, because RFC 6455 SHA-1 for the WebSocket accept key panics under only. It covers the cryptography Parley performs: token hashing, plugin-secret AES-GCM, TLS it initiates to Postgres and the IdP. Browser TLS is the reverse proxy's; Postgres server-side TLS and the IdP are theirs. See Cryptography.

Passcodes are stored in plaintext. A code exists to be read off the page and passed to a colleague. Hashing it would mean no member could look it up again. The consequence is that a database dump discloses every space’s code — which is why a code is treated as a door, not as an identity.

Membership is the space trust boundary. Facilitator checks narrow story and session control, and owner checks narrow membership management, but any member may still change or remove the space’s passcode.

Anonymous participation is the default. It is the product on a trusted network; public deployments need an additional access and abuse-control layer.

  • Can we require SSO before anyone reaches a room? Only by putting an SSO proxy in front of the whole instance. AUTH_MODE=oidc identifies people, but does not gate space access.
  • Can we revoke one person’s access to a space? Yes — an owner removes them from the space’s member list. Membership is re-read on every request, so their next one sees the stranger view, and their live sockets for that space are closed (immediately on the replica serving the removal, at the next revalidation tick — 30 seconds by default — elsewhere). It is not a ban: they can rejoin with a current passcode, so rotate the code too.
  • Can we sign one person out of the instance entirely? There is no UI for that. Delete that person’s rows from session_tokens; HTTP access stops immediately. WebSocket shared-store detection and removal can take nearly 60 seconds; transport closure then follows subject to the active-write and close-frame deadlines.
  • Can we prove who estimated what? In oidc mode, yes, from the database. Votes and entries carry user_id and timestamps. Who signed in, created or deleted a space, rotated a passcode, changed membership, or minted a guest link is on stdout as a security event line (and custody actions also land in org_audit_log, which is write-only today — no product UI (#399)). See Observability and Organizations.
  • What happens on a breach of the database? Everything is disclosed, including every passcode. Session tokens are hashed, so they are not directly reusable.
  • Is there a disclosure process? Yes — use GitHub private vulnerability reporting (enabled on this repository) or email security@letsparley.io. Note that every release before v0.2.2 can be crashed remotely by a disconnecting client, and v0.1.0 additionally has a throttle bypass. None were retagged; v0.2.2 is the floor and v0.10.0 is the current release.

NIST SP 800-171 3.3 (Audit and Accountability)

Section titled “NIST SP 800-171 3.3 (Audit and Accountability)”

Parley is not a complete 800-171 system. These two controls are the ones this page can speak to from what the process itself emits.

Control What Parley does What you still do
3.3.1 Create, protect, and retain information system audit records to the extent needed to enable monitoring, analysis, investigation, and reporting of unlawful, unauthorized, or inappropriate information system activity One JSON security event line on stdout per sign-in, sign-out, space create and delete, passcode rotate and remove, member add, remove and role change, guest-link mint and revoke, and every org_audit_log write. The line names event, actor_user_id, actor_subject, org, space, target, outcome, client_addr and request_id. Cookies, tokens, passcodes and request bodies are never in it. Ship and retain stdout. Parley does not rotate, encrypt, or store those lines itself. The Postgres org_audit_log table has no read API (#399).
3.3.2 Ensure that the actions of individual information system users can be uniquely traced to those users so they can be held accountable for their actions actor_user_id is the Parley user id. actor_subject is the identity-provider subject in oidc mode, open for an anonymous account, and guest for a redeemed signed link. Correlate with the echoed X-Request-Id. oidc mode is what makes the subject an IdP identity rather than open. Vote and standup content is still only in the database rows, not on this line.