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.
What Parley is
Section titled “What Parley is”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.
The one-paragraph summary
Section titled “The one-paragraph summary”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 inventory
Section titled “Data inventory”| 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.
Controls
Section titled “Controls”| Capability | Status | What to do instead |
|---|---|---|
| Vote secrecy before reveal | Built in | Enforced in the serializer, so unrevealed votes are absent from API responses, WebSocket frames and CSV exports alike. |
| Resource existence not disclosed | Built in | Non-members receive 404 rather than 403 for sessions, including on the WebSocket upgrade. |
| Cross-site request defence | Built in | SameSite=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 handling | Built in | 32 random bytes, SHA-256 at rest, HttpOnly cookie, Secure when BASE_URL is https. |
| OIDC correctness | Built in | Authorization code + PKCE (S256); signature, audience, expiry and nonce verified; state and nonce compared in constant time. |
| Long-lived session revocation | Built in | WebSockets 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 passcodes | Built in | 8 attempts per 60s per address per space, with a confusable-free alphabet and constant-time comparison. |
| CSV formula-injection escaping | Built in | Cells starting = + - @ tab or CR are quote-prefixed. |
| Container hardening | Built in | distroless static non-root; the k8s manifest adds drop ALL, no privilege escalation, seccomp RuntimeDefault, read-only root filesystem. |
| Transport security | Not built | Parley never terminates TLS and does not send HSTS. Terminate at a proxy and add HSTS there. |
| Space owner role | Built in | members.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 custody | Built in | An 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 logging | Built in | One 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 erasure | Not built | Application 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 limiting | Not built | Identity 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. |
| Metrics | Built 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 tracing | Not built | JSON logs only. Blackbox-probe /readyz and take request metrics from your proxy. |
| SBOM and provenance | Built in | Releases 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 rest | Not built | Use an encrypted volume or your database provider's encryption. |
| FIPS 140-3 cryptography (800-171 3.13.11) | Built in | The -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. |
Accepted risks
Section titled “Accepted risks”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.
Questions a reviewer usually asks next
Section titled “Questions a reviewer usually asks next”- Can we require SSO before anyone reaches a room? Only by putting an SSO proxy
in front of the whole instance.
AUTH_MODE=oidcidentifies 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
oidcmode, yes, from the database. Votes and entries carryuser_idand timestamps. Who signed in, created or deleted a space, rotated a passcode, changed membership, or minted a guest link is on stdout as asecurity eventline (and custody actions also land inorg_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. |