Self-hosted GRC for SOC 2, ISO/IEC 27001:2022 and PCI DSS v4.0.1 — controls, evidence, vendors, risk and access reviews in one audit-ready system, ending in a sealed package your assessor can verify without you.
Install · What it does · Audit packages · Architecture · Configuration · Operations · conformiti.app
|
Start here The product |
Running it Under the hood Everything else |
git clone https://github.com/dboudreau00/Conformiti.git && cd Conformiti
docker compose up -d --buildOpen http://localhost:8080 and create the first account:
docker compose exec backend python manage.py createsuperuserThat is the whole install: PostgreSQL, Redis, the API, the reminder worker and
nginx come up with production-safe defaults — DEBUG off, a unique secret key
generated and persisted on first boot, rate limits in Redis shared across
workers, and the API published only on the host's loopback so the network
sees nothing but nginx. No .env is required.
Nothing to build? The same two images are published for linux/amd64 and
linux/arm64 at
ghcr.io/dboudreau00,
and a second compose file runs them:
docker compose -f docker-compose.yml -f docker-compose.ghcr.yml up -dPrefer a script that waits for the stack to report healthy and prints the URLs?
./install.sh --docker # macOS / Linux / WSL
.\install.ps1 -Docker # Windows PowerShellLocal development without Docker — SQLite, console email, nothing left running:
./install.sh # or: .\install.ps1Want the worked example instead of an empty installation? Start with
SEED_DEMO_DATA=truefor a seeded organisation and five personas sharing one generated password, printed once in the backend log. It is off by default because those accounts have no second factor, and an installation carrying them says so on its own sign-in page. Retire them before any real data goes in:docker compose exec backend python manage.py remove_demo_dataFull sequence: Day one, in order.
Most compliance programmes are held together by a control matrix in Excel, a folder of policies nobody has opened since the last audit, and a heroic effort in the six weeks before fieldwork. Three things reliably break:
| The question | What usually happens | What Conformiti does |
|---|---|---|
| "Where is the evidence for CC6.1?" | Somebody greps a shared drive | Evidence lives in a tree generated from the control libraries; every document declares which controls it satisfies, and every control lists its documents |
| "When was this policy last reviewed?" | 2023, and nobody noticed | Every document carries a cadence and a next-review date; owners are emailed at 30 / 14 / 7 / 1 days and once when overdue, each window sent exactly once |
| "Just give the auditor read access to the drive" | Access that outlives the engagement | A sealed, signed package issued to named auditors for a fixed window, that verifies offline and expires on its own |
Readiness is measured, not drawn: implemented ÷ applicable, snapshotted daily, per framework. Nobody types a percentage into this system.
There is no telemetry, no phone-home, no licence server, and no seat meter in the code. It is MIT, and it is meant to be run by the organisation that uses it.
- Three complete control libraries — SOC 2 (61), ISO/IEC 27001:2022 (93), PCI DSS v4.0.1 (63) — 217 controls, with a cross-framework crosswalk, per-control status and owner, and a CSV export of the register.
- Evidence ↔ control mapping in both directions. One Access Control Policy
can satisfy
CC6.1,A.5.15and7.1at once; one control can cite many documents. Edit the link from either side; bulk-attach during audit prep, and the bulk action reports what it skipped and why. - A control owner is an account, not a text field. That is what makes the ownership-coverage figure meaningful, what routes the reminder email, and what lets a control owner answer a PBC line without seeing the rest of the audit package.
- Readiness that is measured. Daily snapshots feed the dashboard trend and the month-over-month delta. Marking a control not applicable removes it from the denominator — and the justification is timestamped in the audit trail, which is exactly what an assessor asks for.
- A folder tree segregated by control, generated from the libraries (framework → category → control), created on disk and mirrored in the app, with your own subfolders wherever you want them.
- Per-folder grants by role or by user at
view/edit/manage, inherited down the tree. Effective access is resolved server-side; the interface only offers a write control where the API would accept the write. - Document lifecycle — versions (the old file is archived, not overwritten), rename, move, mark-reviewed; review cadences from monthly to biennial.
- Review reminders emailed to owners and the compliance address at configurable lead times, and once when overdue (which also marks the document expired). Each window is recorded on the document, so a restart does not re-send yesterday's mail.
- Malware scanning of uploads when ClamAV is configured — new documents and new versions, form templates, meeting minutes — with a health probe, an outage alert, and an hourly re-scan sweep that quarantines a stored file the new definitions match.
Downloading evidence in order to look at it is how copies of your policies end up in Downloads folders on laptops you do not control. The viewer renders in place, and it is deliberately conservative:
| Type | How it is rendered |
|---|---|
| Drawn by pdf.js onto canvases. No plugin frame, no scripting from the file | |
| Images | Streamed inline only after a magic-byte check on the actual bytes — never on the extension |
| Word, Excel | Parsed on the server into structured JSON and rendered as structure. The file's own markup never reaches the page |
| Anything else | Offered as a download rather than guessed at |
The wrapper shows the version, the controls the document satisfies, and a SHA-256 computed in your browser with WebCrypto — the same digest a sealed audit package records, so a reviewer can compare by eye.
- Vendor register with tier, data handled, owner and a review clock; assurance on file — SOC 2 reports, ISO certificates, PCI AOCs, pen tests, DPAs, a copy of their own responsibility matrix — with expiry tracking. Posture and risk rating are computed from what is on file and how close it is to lapsing, not typed into a dropdown in 2024 and forgotten.
- The questionnaire, sent to the vendor. One click emails their contact a personal, time-boxed link (14 days by default, 90 maximum, one live link per vendor, revocable). They answer in a browser with no account; the token is stored hashed; the submission returns as a pending assessment marked Returned by … for you to accept, note exceptions against, or reject.
- Shared responsibility matrix per vendor — provider / customer / shared with a statement each side, over every control in scope. Type it, be walked through the unstated controls, or import the vendor's own CSV/XLSX: the importer scores headers to find the right columns, promotes a mark column by the values inside it, treats the vendor's name or acronym as the provider column, requires the framework to be stated rather than inferred, and reports prose it does not recognise instead of guessing. Nothing is written until you confirm what it read.
- Export in their layout — the stated matrix goes back to the vendor under the column headers of the file they sent you.
- RACI matrix per control for people and vendors, with the control owner as implied Accountable and a vendor's matrix as implied Responsible. Exactly one Accountable is enforced at the API, and the controls with none are counted and shown — that count is the point.
- Onboarding prompts in the notification tray when a vendor has no matrix, a report is about to lapse, or a review falls due — plus a bridge-letter reminder, in the tray and by email, when a SOC report has lapsed with nothing newer on file.
- Risk register — likelihood × impact on the 5×5 grid auditors expect, with treatment, owner, due date, optional linked control and Jira key, and a note trail anyone with access can add to. CSV/XLSX import that recognises the column names and word scales people actually use (Title/Risk, Likelihood/Probability, Impact/Severity, High, Likely, Almost certain…) and skips duplicates by title. CSV export that round-trips.
- User access reviews — snapshot every account as it stands (role, last login, folder grants, capabilities) into a keep / modify / revoke decision grid, so it cannot shift under you while you work through it. The API refuses to complete a review while any row is pending; a completed review is read-only evidence from that moment.
- Meeting cadences with required-per-year tracking, where the status badge compares minutes recorded against what the calendar demands so far — a series is not marked behind in January for a meeting due in November.
- Champion groups with an accountable owner and members tagged by department.
- Jira (optional) — an administrator connects an Atlassian site (base URL,
account email, API token, stored server-side and never sent to the browser)
and tracks boards by id; everyone can then read those boards without a Jira
seat.
https://public hosts only, redirects refused, SSRF-hardened. - Immutable audit trail — every change made through the API, plus sign-in, failed sign-in (with the reason) and sign-out, with actor, record, the field names submitted and the IP. Values are never recorded, and password/token/code keys are dropped before the entry is written. There is no endpoint that edits or deletes one.
- TOTP two-factor auth and passkeys / security keys (WebAuthn), alone or together, with backup codes owned by the account so a passkey-only person still has a recovery path. A credential whose signature counter regresses looks cloned: Conformiti disables it and refuses the sign-in rather than dropping the account to password-only.
- HttpOnly cookie sessions by default with
__Host-/__Secure-prefixes derived from the deployment, plus rotating, revocable refresh tokens and per-client login throttles shared across workers through Redis. - Single sign-on over OpenID Connect or SAML 2.0 (Okta, Entra ID, Google Workspace, Keycloak…), configured from the environment only — there is no form an attacker can reach. Verified-email linking never attaches to an administrator, staff or user-managing account; auto-provisioning refuses user-managing roles; a domain allow-list applies; the issuer is compared with trailing slashes stripped; JWKS verification is asymmetric only.
- Step-up MFA on SSO logins —
off,if_enrolledorrequired— for when the provider asserted no second factor. - Five built-in roles plus custom roles, folder-level grants, and an API that enforces every rule the UI shows.
- Security headers and CSP on by default; uploads size-capped, typed and served as sandboxed attachments; field-level encryption for the TOTP seed and the Jira token.
One installation can serve several organisations. Everything an organisation owns belongs to its workspace, and the scoping is applied at the ORM — a queryset carries its workspace filter every time it is chained, so a view that forgets to scope still cannot leak. A person from one organisation cannot list, fetch or even reference another's rows.
- A superuser creates workspaces under Settings › Role & access, switches
between them (
X-Workspace: <slug>; the SPA remembers the choice) and archives one — which refuses its people at sign-in, rejects the tokens they already held for as long as it stays archived, and drops it from every scheduled job. Nothing is deleted. - Scheduled work runs once per workspace: review, vendor and auditor-request scans, the daily chat summary, readiness snapshots. Digests are computed in the person's own workspace.
- A single-organisation install has one workspace called Default holding everything it already had, and never notices.
- Not tenant-scoped, deliberately: the workspace list itself, per-person authentication state (passkeys, TOTP, backup codes, SSO identities), the signing-key registry, the scanner status row, notification receipts and webhook deliveries. Those belong to the installation or to the individual.
- Not yet per workspace: the single sign-on provider (one IdP for the
installation;
SSO_WORKSPACEnames the workspace it provisions into) and the Slack/Teams webhook (one channel, each post carrying the workspace name once there is more than one). Both are next on the roadmap.
- The tray is computed for you: documents and risks you own that are due
or overdue, tasks assigned to you, meeting cadences you own that are behind.
Managers get org-wide digests; administrators and auditors see open access
reviews. Opening the tray marks items read;
×dismisses one. - Slack and Microsoft Teams by incoming webhook: a package sealed, issued or withdrawn; the auditor raising a request or returning an answer; a vendor's questionnaire coming back; the malware scanner going quiet or recovering; a file quarantined; and a daily summary of what is outstanding. Slack receives Block Kit, Teams an Adaptive Card, and every delivery is logged. A webhook URL is a credential, so it is stored encrypted, never returned by the API, and may only address a host those services actually issue webhooks on — checked, resolved and pinned before every post, with redirects refused.
- Digest email — each person can have their own tray sent daily or weekly.
Four theme packs (Audit Ledger, Nimbus, Ledger Dark, Obsidian), four accent packs and a custom accent colour, applied before first paint and remembered per browser. Keyboard-accessible throughout.
The mark is a shield split along its centreline with one check struck across
it, in four colourways with fixed meanings: Governance Blue is the
corporate mark; Assurance Green means controls passing and audit ready;
Risk Red is reserved for findings, escalations and alerts and is never the
lockup; Policy Purple stands for frameworks and attestations. Sources in
assets/brand/ and frontend/src/brand.js.
This is the feature the rest of the product exists to feed.
Audit time usually means granting an external assessor read access to a folder tree and hoping somebody remembers to take it away afterwards. Audit packages replace that ritual.
ASSEMBLE ─────────▶ SEAL ─────────▶ ISSUE ─────────▶ VERIFY
controls in scope canonical one named sha256sum -c
evidence pinned manifest + auditor, fixed python3 verify.py
population stated Ed25519 sig window no vendor involved
assertion written audit entry package only signature checked offline
1 · Assemble. A compliance manager picks the controls in scope, pins the evidence for each one, states each control's population (size, source, sampling method) and may list the items, then writes the management assertion.
2 · Seal. Sealing snapshots every row — control reference and text, status,
owner, document name, version, size and SHA-256 — into a canonical manifest
with its own digest, and signs that manifest with a detached Ed25519
signature from a key held in a file outside the database
(SIGNING_KEY_FILE). The package freezes: the assessed organisation can no
longer change what the auditor is looking at. A seal entry goes into the audit
trail, and the key fingerprint is published under Settings › About and at
/api/signing-keys/.
3 · Issue. The package is issued to named auditors, each for a fixed period. They sign in and see that package and nothing else — the only bypass of the folder-permission model in the product, and a deliberate, audited one. They record a design and an operating conclusion per control, which nobody at the assessed organisation can edit, and you answer beside them with a management response. An exception can be raised into the risk register in one click, arriving with the package and control already referenced.
4 · Verify. They leave with one self-verifying ZIP:
conformiti-package-fy26-soc2/
├── manifest.json canonical, one digest over everything
├── manifest.sig detached Ed25519 signature
├── signing-key.pub the public key, to compare against the published fingerprint
├── SHA256SUMS every file, hashed
├── verify.py standard library only — no pip install, no network
├── controls.csv scope, status, owner, both conclusions
├── evidence.csv name, version, size, digest
├── samples.csv population, selections, per-item verdicts
├── audit-trail.csv what happened, and when
└── evidence/ the files themselves
sha256sum -c SHA256SUMS
python3 verify.pyAccess expires, or is withdrawn in one click. The record of what was disclosed, to whom, and every file they opened, is permanent.
Operating effectiveness is tested on sampled items, so the package holds them.
The organisation states the population while the package is a draft and may
list items; those are sealed into the manifest with the artefact supporting
each one. After sealing, the auditor adds their own selections and records
pass, exception or not tested per item, with an exception note that is
required, not optional. The bundle carries the whole workpaper as
samples.csv.
Next year, roll the package forward: the same controls re-snapshotted as they stand today with today's evidence pinned, the old package recorded as the predecessor, and a year-over-year panel showing what entered or left scope, which evidence was replaced, and which of last year's exceptions are still open. The manifest names its predecessor, so a chain of engagements verifies end to end.
The other half of the workflow: what the auditor has asked for. The auditor raises lines from inside the package (or you transcribe the list they emailed); each one is assigned, dated and chased — in the tray, by email, and in Slack or Teams — and answered by attaching documents and marking it provided. The auditor accepts it or returns it with a note. A control owner with no package access still sees and answers the lines assigned to them.
A signature proves that the holder of a key signed a manifest. It cannot prove the key was never stolen, and it cannot prove when it was signed.
The seal entry in the audit trail, and a digest you publish out of band — an email to the assessor, a ticket, a signed message — are the other half of that binding. Keep the signing key off the database host, back it up separately, and publish the fingerprint where your auditor can compare it.
| # | Do this | Why |
|---|---|---|
| 1 | docker compose up -d --build |
The stack comes up with production-safe defaults |
| 2 | manage.py createsuperuser |
Your first real administrator. No demo dataset is seeded unless you asked for one |
| 3 | manage.py remove_demo_data (--delete to remove rather than deactivate) |
Only if you did ask: those accounts share one password and have no second factor |
| 4 | Set DJANGO_ALLOWED_HOSTS, CSRF_TRUSTED_ORIGINS, CORS_ALLOWED_ORIGINS, PUBLIC_URL |
The moment you leave localhost. Sending a vendor questionnaire is refused until PUBLIC_URL is set, because the link carries a bearer token |
| 5 | Put TLS in front and set BEHIND_TLS=true |
Secure cookies, HTTPS redirect, __Host- prefixes — the prefix only works over https |
| 6 | Configure EMAIL_PROVIDER, then manage.py test_mailbox --to you@example.com |
Reminders are half the product |
| 7 | Enrol a second factor on every account with a management capability | TOTP or passkeys; backup codes belong to the account |
| 8 | Back up the secrets volume | It holds DJANGO_SECRET_KEY_FILE and the package signing key |
| 9 | Restore from a backup once, into a scratch environment | An untested backup is a finding in most frameworks and a disaster in all of them |
| Path | Needs |
|---|---|
| Docker (recommended) | Docker Engine 24+ with the Compose plugin. 2 vCPU / 4 GB RAM / 20 GB disk is comfortable |
| Local (trial, development) | Python 3.11–3.14, Node 20.19+ or 22.12+. SQLite, console email, nothing to run |
| Production | PostgreSQL 16, Redis 7, a TLS-terminating proxy, an SMTP/SES sender, a backup target |
| Optional | Amazon S3, ClamAV, an OIDC or SAML IdP, a Slack/Teams webhook, Jira Cloud |
Full detail: PREREQUISITES.md · INSTALL.md · a guided first hour in GETTING_STARTED.md.
| Image | Holds |
|---|---|
ghcr.io/dboudreau00/conformiti-backend |
Django, Celery and gunicorn: the API, the scheduler and the worker all run from this one image |
ghcr.io/dboudreau00/conformiti-frontend |
the built interface, served by nginx, which also fronts the API |
Both are built for linux/amd64 and linux/arm64, so the same tag runs on an
Ampere or Graviton VPS and on an Apple Silicon laptop. Each release is tagged
with its version (0.9.5i), with the first seven characters of the commit it
was built from (sha-…), and the newest release also answers to latest. The version an image carries
is read out of backend/config/version.py at build time, which is the same
string /api/health/ reports, so a running container cannot claim a version
its code is not.
docker pull ghcr.io/dboudreau00/conformiti-backend:0.9.5i
docker compose -f docker-compose.yml -f docker-compose.ghcr.yml pull
CONFORMITI_VERSION=0.9.5i docker compose -f docker-compose.yml -f docker-compose.ghcr.yml up -ddocker-compose.ghcr.yml only swaps the four built services for the published
images. The environment, the volumes, the healthchecks and the single published
port are the ones in docker-compose.yml, so an installation assembled this way
is the same installation. Pin CONFORMITI_VERSION in production: latest moves.
Building from source stays the default path, and the images are built from the
same Dockerfiles by
.github/workflows/packages.yml, which then
pulls what it pushed and boots it before the run is allowed to pass.
Five containers, five volumes, one published port. The API listens on loopback inside its own container and is unreachable except through nginx, which also carries the CSP, the security headers and the 32 MB body cap.
browser ─▶ nginx (frontend, :8080) ─┬─▶ gunicorn (backend, 127.0.0.1:8000) ─▶ PostgreSQL
│ ▲ healthcheck /api/health/ └─▶ Redis (cache + broker)
├─ /static, /media from shared volumes
└─ CSP, security headers, 32 MB body cap
celery worker + beat ──────────────────────────────────────────────▶ Redis / PostgreSQL / email
volumes: pgdata · media · static · secrets · tree
Never add
Content-Dispositionin anX-Accellocation. nginx passes the upstream header through, so adding one produces two — and browsers refuse the response. The API owns that header. This is called out because it was a real bug between 0.3.0 and 0.5.0; if you customisenginx.conf, do not reintroduce it.
Everything is environment-driven. The compose file carries production-safe
defaults; .env overrides them. Every key is documented in
.env.example. The ones that matter most:
| Setting | Purpose |
|---|---|
DJANGO_DEBUG |
false in Docker by default; true only on the local dev path |
DJANGO_SECRET_KEY / DJANGO_SECRET_KEY_FILE |
A strong key, or a path where one is generated and persisted (compose uses the file form on the secrets volume) |
DJANGO_ALLOWED_HOSTS, CSRF_TRUSTED_ORIGINS, CORS_ALLOWED_ORIGINS |
Your real hostname(s) once you leave localhost. Getting these wrong is the most common cause of an install that runs but refuses logins |
BEHIND_TLS |
true once a TLS-terminating proxy sits in front of nginx |
PUBLIC_URL |
The address links mailed outside the organisation point at. Required off DEBUG: a questionnaire link carries a bearer token, so rather than guess the host from the request, sending is refused until this is set |
ORGANISATION_NAME |
Your name in outbound email and on the page a vendor sees |
SEED_DEMO_DATA |
true to boot with the demo dataset. Off by default: an installation carrying it says so on its own sign-in page |
DJANGO_SUPERUSER_USERNAME / _PASSWORD |
Create your first account on first boot |
| Setting | Purpose |
|---|---|
DATABASE_URL |
PostgreSQL in Docker and production; SQLite locally |
EMAIL_PROVIDER |
console · smtp · mailbox (IMAP/POP3 + SMTP, with a copy filed in Sent) · ses |
REVIEW_SCAN_HOUR, REVIEW_ALERT_LEAD_DAYS |
When the daily scan runs; how far ahead it warns (30, 14, 7, 1 by default) |
S3_* |
Optional Amazon S3 for evidence instead of the local filesystem |
MAX_UPLOAD_MB, PASSWORD_MIN_LENGTH, THROTTLE_LOGIN |
Upload cap (32 MB default), password policy, per-client login throttle |
| Setting | Purpose |
|---|---|
OIDC_* |
Issuer, client id/secret, scopes, domain allow-list, auto-provisioning. PKCE; asymmetric JWKS verification only |
SAML_* |
IdP metadata, entity id, ACS URL, signing certificate. Assertions are replay-checked; HMAC signature methods are refused |
SSO_STEP_UP |
off · if_enrolled · required — whether an SSO sign-in must also present a local second factor |
SSO_WORKSPACE |
Which workspace an auto-provisioned SSO account joins (default default) |
WEBAUTHN_RP_ID, WEBAUTHN_ORIGINS, WEBAUTHN_RP_NAME, WEBAUTHN_USER_VERIFICATION |
RP_ID must be a domain — browsers refuse an IP address, including 127.0.0.1. Pin both when a proxy rewrites Host |
| Setting | Purpose |
|---|---|
SIGNING_KEY_FILE / SIGNING_KEY |
Where the Ed25519 package-signing key lives. In compose: /app/secrets/package_signing_key. Rotate with manage.py rotate_signing_key |
CLAMAV_* |
Point at a clamd instance to scan uploads, with a health probe, an outage alert and an hourly re-scan sweep |
SLACK_WEBHOOK_URL, TEAMS_WEBHOOK_URL |
Installation-wide incoming webhooks. A webhook URL is a credential, so it may only address a host Slack or Teams actually issues them on (WEBHOOK_ALLOWED_HOSTS_SLACK / _TEAMS to change), its address is checked before every post and a redirect is refused. Each workspace can carry its own under Settings › Workspaces, never readable back through the API; with more than one workspace the installation-wide pair is held back unless WEBHOOKS_SHARED_ACROSS_WORKSPACES=true |
REDIS_PASSWORD |
Compose only: puts AUTH on the queue, result store and cache. Letters and digits |
| Role | Manage users | Manage frameworks | Manage documents | Manage folders | View all | Auditor |
|---|---|---|---|---|---|---|
| Administrator | ✓ | ✓ | ✓ | ✓ | ✓ | – |
| Compliance Manager | – | ✓ | ✓ | ✓ | ✓ | – |
| Control Owner | – | – | ✓ (granted folders) | – | – | – |
| Auditor | – | – | – | – | ✓ (granted folders, read-only) | ✓ |
| Viewer | – | – | – | – | – | – |
Custom roles are defined from the same capability flags.
The Auditor role is scoped to the engagement. It is the one role held by
someone outside the organisation, so reads are refused by default rather than
granted by default: an auditor reaches the packages issued to them, the
workpaper rows and evidence in those packages, their own request list, the
folders granted with the package, the access reviews and the audit log — and
nothing else. The risk register, the vendor file, the control library, the
responsibility matrix, the meeting minutes, the calendar, the user directory
and the analytics summary all answer 403. Someone inside the company who
needs a read-only view of the programme wants the Viewer role instead.
Effective folder access — Folder.effective_access(user) returns the
highest of:
manageif superuser orrole.can_manage_foldersviewifrole.can_view_allmanageif the user owns the folder- the strongest
FolderPermissionfor the user or their role on this folder or any ancestor (inheritance)
…then an Auditor role is capped at view. Documents inherit their folder's
access; a document owner may always edit their own document, but deleting it
requires manage on the folder. Re-parenting requires manage on the folder
and edit on the destination; the generated framework folders are immutable
through the API.
documents.access.accessible_folder_ids(user) resolves the same rules in a
handful of queries and scopes every list, tree, feed, evidence count and
analytics figure.
Demo accounts (retire them): admin, mia, owen, aria, val — all
the password printed when the demo data was seeded.
Prefix each command with docker compose exec backend python on the Docker
path, or ../.venv/bin/python from backend/ locally.
In Docker, Celery beat runs all of this. Without Docker, put them on cron.
manage.py send_review_reminders [--dry-run] # the review/document scan
manage.py record_readiness # today's readiness snapshot
manage.py scan_evidence # re-scan sweep + scanner watch
manage.py send_digests # per-person daily/weekly digest
manage.py flushexpiredtokens # prune the JWT blacklist--dry-run prints what the scan would send without sending it — worth
running once after any mail configuration change.
manage.py createsuperuser
manage.py seed_frameworks --with-folders # idempotent; re-sync after an upgrade
manage.py seed_frameworks --roles-only
manage.py remove_demo_data [--delete] [--workspace <slug>]
manage.py rotate_signing_key # new Ed25519 key; old public key stays published
manage.py link_oidc_identity
manage.py test_mailbox --to you@example.comscripts/backup.sh # → backups/<UTC timestamp>/
scripts/backup.sh /mnt/nightly # or a directory of your choosingOne script, run from the checkout while the stack is up. It writes the
database dump (db.sql.gz), the evidence files (media.tgz — a database
without these is a manifest of things you no longer have), the secrets volume
(secrets.tgz — the Django secret key, the field-encryption ring that
protects enrolled authenticators, and the package signing key) and the folder
tree on disk (tree.tgz). It asks the running containers for the database
credentials and the volume names, so it needs no configuration. Put it on
cron and copy the directory somewhere else; CI runs it, destroys the
installation and restores from it on every push.
Losing the signing key does not invalidate signatures already issued — the public key travels in every bundle — but you will not be able to sign with the same identity again, and roll-forward chains will change key. Losing the field-encryption ring makes enrolled authenticators unreadable; backup codes still work.
scripts/restore.sh backups/<UTC timestamp>On the same machine or a fresh one (clone the same release first). The
application containers are stopped, the database is emptied and reloaded,
the three volumes are replaced from the archives and the stack is started
again. Check docker compose ps and /api/health/ afterwards; the signing
key reported there should be the one you had.
GET /api/health/ reports version, database, cache, mail, scanner and signing
status. It is what the container healthcheck uses, and the first thing to
attach to a bug report.
Upgrade notes for each release, including migration counts and what to budget for them, are in CHANGELOG.md.
scripts/backup.sh # first, always
git fetch --tags && git checkout v0.9.5i
docker compose pull && docker compose up -d --buildRunning the published images instead? The checkout still matters, because the compose file, the nginx configuration and the backup scripts come from it:
scripts/backup.sh
git fetch --tags && git checkout v0.9.5i
CONFORMITI_VERSION=0.9.5i docker compose -f docker-compose.yml -f docker-compose.ghcr.yml pull
CONFORMITI_VERSION=0.9.5i docker compose -f docker-compose.yml -f docker-compose.ghcr.yml up -dThe backend container applies the shipped migrations and re-seeds the control
libraries in every workspace at boot, so the two manage.py steps earlier
releases asked for are no longer needed; running them is harmless.
0.9.5b is one migration, which encrypts the two per-workspace webhook
columns in place. Two behaviour changes will look like faults if you are not
expecting them: a workspace's webhook URLs are no longer returned by the API,
so the settings screen shows whether a channel is configured rather than its
address, and the demo dataset is no longer seeded, so a rebuilt installation
comes up empty unless you set SEED_DEMO_DATA=true.
0.9.5 adds two small columns (a score on each readiness snapshot, and the
per-workspace webhook addresses) and a beat service to the compose file —
the worker no longer runs the scheduler itself, so docker compose up -d
after the checkout is what starts it. 0.9.0 is ten migrations, one per
app: each adds the workspace column, moves every row into the Default
workspace and makes the column required, inside one transaction on
PostgreSQL. Budget a few seconds per hundred thousand rows.
| Layer | Technology |
|---|---|
| Backend | Python 3.11–3.14 · Django 5.2 LTS · Django REST Framework · SimpleJWT |
| Async | Celery 5 + Redis — daily reminder scan, vendor and PBC scans, readiness snapshot and chat summary (each once per workspace), digest emails, hourly scanner watch, weekly token pruning |
| Frontend | React 19 · React Router 7 · Vite 8 · Tailwind CSS · framer-motion · lucide · pdfjs-dist |
| Database | SQLite (local) · PostgreSQL 16 (Docker / production) |
| Storage | Local filesystem · Amazon S3 optional |
| Console · SMTP · IMAP/POP3 mailbox account · Amazon SES | |
| Crypto | Ed25519 manifest signatures; a from-scratch, standard-library verifier tested against RFC 8032 vectors |
flowchart LR
U[Browser · React SPA] -->|/api, /admin, /media| N[nginx]
N --> A[Django REST API]
A --> P[(PostgreSQL)]
A --> R[(Redis · cache + broker)]
W[Celery worker + beat] --> R
W -->|review reminders| M[Email]
A -->|optional| S[(S3)]
A -->|optional| J[Jira Cloud]
Deeper notes: docs/ARCHITECTURE.md.
| App | Responsibility |
|---|---|
accounts |
Custom User, Role (capability flags), RBAC permission classes, TOTP MFA + backup codes, passkeys (webauthn.py protocol, passkeys.py glue), OIDC and SAML SSO, workspaces and the tenancy machinery, sign-out, demo retirement, blacklist pruning |
compliance |
Framework, ControlCategory, Control, ControlMapping (crosswalk), ControlEvidence, Responsibility (RACI), the seed and on-disk folder tree, controls CSV export |
documents |
Folder (self-parent tree with a cycle guard), FolderPermission, Document (+ scan verdict / quarantine), DocumentVersion, upload validation, the clamd client, the scanning boundary and the scanner watch, preview.py |
governance |
Risk + RiskNote (+ the CSV/XLSX importer), AccessReview + snapshot items, MeetingSeries + minutes, ChampionGroup + members |
vendors |
Vendor, VendorAssessment, SharedResponsibility + the CSV/XLSX recogniser (matrix.py), QuestionnaireInvite and the public token endpoints |
attestations |
EvidencePackage → PackageControl → PackageEvidence / PackageSample, PackageGrant (the audited folder-permission bypass), manifest and bundle, PbcRequest / PbcItem, roll-forward and the year-over-year diff, Ed25519 signing and the SigningKey registry, the stdlib verifier.py shipped in every bundle |
notifications |
Reminder scans, email transports, the derived per-user feed with receipts, digest emails, Slack/Teams webhooks with a delivery log |
audit |
AuditLog, the request middleware, explicit auth events, a read-only viewer API |
analytics |
The dashboard summary endpoint, ReadinessSnapshot history and trend |
calendar_app |
CalendarEvent plus the merged review / audit / task feed |
integrations |
The Jira Cloud client — https-only, public-IP pinned, no redirects |
config |
Settings, URLs, the health endpoint, the version, the CSV sanitiser |
Framework 1─* ControlCategory 1─* Control *─* ControlMapping
│ 1─* ControlEvidence *─1 Document
User(Role) ─owns→ Control / Folder / Document / Risk
Folder (self-parent tree; framework/category/control FKs on seeded nodes)
│ 1─* Document 1─* DocumentVersion
│ └─ owner, review_cadence, next_review_date, reminders_sent,
│ scan verdict / quarantine
└─ FolderPermission (role|user → view/edit/manage, inherited downward)
Risk 1─* RiskNote AccessReview 1─* AccessReviewItem (snapshot)
MeetingSeries 1─* MeetingMinute ChampionGroup 1─* GroupMember
CalendarEvent → optional Document / Control / assignee
Vendor 1─* VendorAssessment
├─ 1─* SharedResponsibility (the provider/customer/shared matrix)
├─ 1─* Responsibility (RACI rows — note: a different relation)
└─ 1─* QuestionnaireInvite (token hash only)
EvidencePackage 1─* PackageControl 1─┬─* PackageEvidence
└─* PackageSample
├─ 1─* PackageGrant (the audited bypass)
├─ 1─* PbcRequest 1─* PbcItem
└─ prior_package → the roll-forward chain
AuditLog · ReadinessSnapshot (one per day) · NotificationReceipt (user, key)
MfaDevice 1─* MfaBackupCode · WebAuthnCredential · SigningKey · WebhookDelivery
every organisation-owned model above → workspace_id
Vendor.responsibilitiesare the RACI rows; the shared-responsibility matrix rows areVendor.shared_responsibilities. Confusing the two makes every vendor look unstated.
accounts.Workspace is the tenant. Every organisation-owned model inherits
accounts.tenancy.TenantModel: a workspace foreign key and a manager whose
querysets carry WHERE workspace_id = <active> whenever a workspace is active.
The active workspace is a context variable.
WorkspaceMiddlewareinstalls a per-request resolver that reads the workspace off the authenticated person the first time a tenant query runs — DRF authenticates inside the view, after middleware, so it has to be lazy. A superuser may name another workspace inX-Workspace. The variable is restored when the request ends.- Tasks and commands activate one with
tenancy.scoped(ws)and walk them all withtenancy.for_each_workspace(). - A row saved without a workspace takes it from its declared parent
(
tenant_parent = "folder") or from the active workspace, and refuses otherwise (NoActiveWorkspace). - The filter is re-applied whenever a queryset is chained, so a queryset built
at import time (
queryset = Model.objects.all()on a viewset) is scoped the moment DRF calls.all(). Pinning never widens. - No active workspace means no filter — right for migrations,
createsuperuser, and jobs that walk every workspace. An API request with nowhere to go is refused with 403.tenancy.unscoped()is the explicit escape hatch.
POST /api/auth/token/→ access (60 min) + refresh (7 d). Accounts with a second factor get{"mfa_required": true, "factors": {...}, "passkey"?: {...}}until anotp(authenticator or backup code) or apasskeyassertion is supplied; passkey challenges live inWebAuthnChallengerows that answer once.POST /api/auth/token/refresh/rotates the refresh token and blacklists the old one;POST /api/auth/logout/blacklists the current one.- Session auth remains for the Django admin and, in
DEBUG, the browsable API. - Login, failed login (with reason) and logout are audit events.
Document.last_reviewed + cadence ─▶ next_review_date
│
daily scan at REVIEW_SCAN_HOUR (Celery beat) — or cron: send_review_reminders
│
for each lead in REVIEW_ALERT_LEAD_DAYS (30,14,7,1) not yet sent:
└▶ email_service.send_templated_email()
├─ EMAIL_PROVIDER=ses → boto3
├─ EMAIL_PROVIDER=mailbox → SMTP (+ IMAP Sent copy)
├─ EMAIL_PROVIDER=smtp → Django SMTP backend
└─ EMAIL_PROVIDER=console → stdout
│
record the lead in Document.reminders_sent (dedupe)
overdue → one notice + status=expired
audit.middleware.AuditLogMiddleware reads the top-level field names of a
JSON/form body before the view runs — values are never recorded, and
password, token and code keys are dropped — then, after a successful mutating
response, writes {user, action, object_type, object_id, "METHOD /path fields=a,b", ip}. /api/auth/*, /api/notifications/* and /api/health/ are
excluded; auth events are written explicitly by audit.events.
A React SPA on Vite. App.jsx mounts the shell (Sidebar, TopBar,
ShellContext with the signed-in user, health record and live badge counts)
and the routes; every page is a PanelTransition panel built from the
primitives in components/ui and components/charts. Styling is Tailwind over
the token system in styles/index.css: a theme pack (data-theme) and an
accent pack or custom colour (data-accent) on <html>, applied before first
paint by public/theme-init.js. The axios client attaches the access token,
refreshes once on 401 (storing the rotated refresh token) and revokes on
sign-out.
Django REST Framework, with the SPA as its first consumer. Anything the interface can do, a script can do — under the same permission checks, and writing the same audit-trail entries.
| Endpoint | Purpose |
|---|---|
/api/frameworks/ · /api/controls/ |
The libraries, statuses, owners, the crosswalk, CSV export |
/api/folders/ · /api/documents/ |
The evidence tree, uploads, versions, review marking, permission grants |
/api/documents/{id}/preview/ |
Grant-gated, audited render for the in-browser viewer |
/api/risks/ · /api/risk-notes/ |
The register, the note trail, the CSV/XLSX importer |
/api/access-reviews/ |
Snapshot creation, per-row decisions, CSV export, completion |
/api/vendors/ |
Register and assessments; /{id}/matrix/ GET, PUT (bulk, validated before write), matrix/parse, matrix/export |
/api/questionnaire/<token>/ |
Public, token-scoped, separately throttled — what a vendor answers with no account |
/api/packages/ · /api/package-samples/ |
Assembly, sealing, issuing, withdrawal, manifest, bundle, per-sample verdicts |
/api/pbc-requests/ · /api/pbc-items/ |
The auditor's request list: provide, accept, return, withdraw, export |
/api/signing-keys/ |
Published Ed25519 public keys and fingerprints |
/api/workspaces/ |
List, create, patch, current |
/api/audit/ · /api/notifications/ · /api/analytics/ |
The read-only trail, the derived feed with receipts, the dashboard summary |
/api/health/ |
Version, database, cache, mail, scanner, signing |
Every list is scoped to the caller's workspace and their folder grants at the queryset level, so a view that forgets to filter cannot leak.
Everything in the badge row runs on every push. You can run the whole thing locally:
./install.sh --test # or: .\install.ps1 -Test| Gate | What it proves |
|---|---|
tools/validate.py — 19 static checks |
App and route wiring, the API contract between the SPA and the backend, that every model change has a shipped migration, theme packs, tests and CI present. Runs on a bare Python interpreter so a missing package cannot defeat it |
manage.py test — 580 tests across 36 modules |
Workspace isolation, auth, MFA, token rotation, the auditor's reachable surface enumerated by walking the routers, RBAC and tree integrity, evidence RBAC, access reviews, risk import/export safety, the audit trail, reminder claims, outbound request checks, field encryption and key rotation, health, demo retirement, the boot guard, WebAuthn against virtual authenticators, SAML against locally signed assertions, Ed25519 against RFC 8032 vectors |
| Backend matrix | Python 3.11 / 3.12 / 3.13 / 3.14 on SQLite, plus PostgreSQL 16 |
| Frontend | A production build that must succeed, plus npm audit --audit-level=high |
| Docker | Both images build; the API image boots and answers /api/health/. The compose job also signs in through nginx, uploads, downloads through X-Accel, backs up, destroys the stack and restores it |
| End-to-end — 93 tests in 13 files | Playwright drives the built SPA in a real browser through every screen, against both auth transports — and fails on any console error |
The three independent reviews this product has been through — findings, severities, fixes and what was deliberately left alone — are in REVIEW.md, REVIEW_090.md, REVIEW_095.md, REVIEW_095F.md, REVIEW_095H.md and REVIEW_095I.md. Operator-facing posture and residual risks: SECURITY.md. How the gates run: TESTING.md and VALIDATION.md.
Symptoms and fixes are collected in one place, with the install steps they belong to:
INSTALL.md. The two most common are a hostname missing from
DJANGO_ALLOWED_HOSTS / CSRF_TRUSTED_ORIGINS / CORS_ALLOWED_ORIGINS, and
BEHIND_TLS=true on a deployment still served over plain HTTP.
conformiti/
├── backend/ Django project (config/) + apps: accounts, compliance,
│ documents, governance, vendors, attestations, notifications,
│ audit, analytics, calendar_app, integrations
│ · testutils.py · Dockerfile · entrypoint.sh
├── frontend/ React SPA (src/pages, src/components, src/styles)
│ · brand.js · Dockerfile · nginx.conf
├── e2e/ Playwright suite — 13 spec files, both auth transports
├── compliance-data/ the generated evidence folder tree (segregated by control)
├── docs/ ARCHITECTURE.md · EXECUTIVE_SUMMARY.md · sample-risk-import.csv
│ · sample-risk-import.csv
├── assets/ brand/ (logo, mark, colourways) · screenshots/
├── tools/validate.py the dependency-free static validator
├── .github/workflows ci.yml · packages.yml (the images on ghcr.io)
├── docker-compose.yml db · redis · backend · worker · frontend
├── docker-compose.ghcr.yml the same stack, from the published images
└── install.sh / install.ps1
Documents in the root: INSTALL.md · PREREQUISITES.md · GETTING_STARTED.md · USER_GUIDE.md · SECURITY.md · REVIEW.md · REVIEW_095F.md · REVIEW_095H.md · REVIEW_095I.md · TESTING.md · VALIDATION.md · CHANGELOG.md · ROADMAP.md · CONTRIBUTING.md
Will an auditor accept evidence from a tool I host myself?
Auditors accept evidence; the tool is not the evidence. What matters is that
the artefact is attributable, complete and unaltered between the moment you
produced it and the moment they read it. A sealed package carries a canonical
manifest, a SHA-256 for every file, an Ed25519 signature over the manifest, an
audit-trail extract, and a standard-library verify.py the auditor runs on
their own machine. That is a stronger chain of custody than a shared folder.
Tell your assessor early that you will hand them a bundle rather than drive access — most welcome it, and the ones who do not can still read the CSVs.
Does it generate policies with AI?
No, deliberately. Conformiti ships control libraries, an evidence model and the machinery to prove what you did. It does not generate policy text you would then have to defend in a walkthrough as your own.
How small a team is this useful for?
The smallest useful deployment is one person preparing for a first SOC 2 Type I — the folder tree and reminder engine pay for themselves immediately. It scales up through a compliance function with control owners spread across engineering, HR and finance, and up again through workspaces to an MSP or a group holding several regulated entities on one installation.
Which frameworks ship, and what about the others?
SOC 2, ISO/IEC 27001:2022 and PCI DSS v4.0.1 ship in this repository, free, with a crosswalk between them. Additional framework libraries — NIST CSF 2.0, HIPAA, CIS Controls v8 and others — are not part of this edition; they are offered as seed packs through conformiti.app, and a custom control set can be modelled the same way. The seeding command and the control model here load any pack built to the same shape.
Can I get my data out?
It was never anywhere else: a PostgreSQL database and a directory of files, both yours. Nothing in this repository calls home, and every export in the product — controls CSV, risk CSV, access-review CSV, the audit package bundle — is a plain file format.
Is there commercial support?
Yes — support subscriptions, a managed cloud, and consulting are offered at conformiti.app. None of it changes this repository: the platform here is the platform there.
0.9.5 is the last version number, and the feature-complete release of the open-source edition. What the repository set out to be, a self-hosted programme of record for SOC 2, ISO 27001 and PCI DSS with sealed and signed audit packages, vendor risk, workspaces and the operations to run it, is here, and every finding of three independent reviews is closed.
Releases after it are revision letters on that number: 0.9.5b, then c, d and so on. Each is maintenance, meaning security fixes, dependency updates and compatibility with new Python, Django and PostgreSQL versions, for as long as people run it. ROADMAP.md has the release-by-release history.
Automated evidence collection from cloud and SaaS accounts, additional framework libraries (NIST CSF 2.0, HIPAA, CIS Controls v8) and the like are not planned for this edition; see conformiti.app for what is offered around it.
See CONTRIBUTING.md. In short:
- Run the gates before opening a pull request:
./install.sh --test. - New models need a shipped migration —
tools/validate.pywill fail the build otherwise, and it runs on a bare interpreter so it cannot be skipped. - Validator checks must be standard library only; the CI
validatejob installs nothing. - Security issues go to the private advisory route, not a public issue — see SECURITY.md.
MIT © 2026 elemosecurity.
Control text and copyright. Control identifiers and short titles are functional identifiers. The
objectivefields shipped in the seed packs are brief original paraphrases, not the normative text of SOC 2, ISO/IEC 27001 or PCI DSS. Only paste official control text into the app if your organisation holds a licence for the source documents — the field exists so that you can, and the responsibility is yours.
Not affiliated with the AICPA, ISO, the IEC or the PCI Security Standards Council. Framework names are used to describe what the control libraries cover.