Skip to content

Access control

Cograph is private by default. New repositories are not public, anonymous reading is off, and self-registration is disabled.

Roles

Three: owner, admin, user.

owner and admin are equivalent in nearly every access check, and the distinction exists for future separation of duties. Treat both as full administrators.

One known exception

The global collection-jobs listing checks for admin specifically, so an owner is scoped there like a normal user — they see only public and owned collections' jobs. Do not build on either behaviour.

What a plain user can reach:

Surfaceuseradmin / owner
Repository catalog, overview, wiki, docs, graphgranted repositories onlyall
Markdown collectionsgranted / public / ownedall
Search console (/search)
Jobs dashboard (/jobs)
Add / reindex / delete repositories
/admin (all tabs)
Own tokens and identities
Own query log
REST API and MCPwithin grantsall

Search is admin-only in the UI

A plain user browsing the UI has no search box. They can still search through the API and MCP within their grants — the gate is on the console, not on retrieval.

Repository visibility

Two values: public and admin_only. New repositories default to admin_only.

admin_only is a misnomer

It means not public, not "administrators only". A plain user holding a group grant on an admin_only repository can read it. The name is inherited from an earlier model where grants did not exist.

Anonymous access requires both public visibility and the deployment-wide COGRAPH_AUTH__PUBLIC_READ=true. Either alone gives nothing.

Existence hiding

An unreadable repository and a non-existent one both return 404. A caller cannot enumerate what exists by watching status codes.

Collections leak existence

Collections diverge: an unreadable collection returns 403 while a missing one returns 404, so the two are distinguishable. Worth knowing if you rely on existence hiding.

Groups and grants

Access beyond public-or-admin goes through groups.

A group holds members and receives grants on repositories and collections at one of two levels:

LevelAllows
readVisible and queryable — UI, REST, MCP
writeAdditionally: run costly jobs — reindex, upload, re-embed, retry

There is no third value. The absence of a grant row is "no access". Administering grants is a role-level power and sits outside this ladder.

write does not include deletion

Deletion is gated by role, not by grant:

ActionRequired
Read a repository or collectionread grant, or public, or admin
Reindex, upload, re-embed, retry a jobwrite grant, or owner, or admin
Delete a repositoryadmin role
Delete a whole collectioncollection owner, or admin role
Delete a document inside a collectioncollection owner, or admin role
Administer groups and grantsadmin role

A group with write on a repository cannot delete it.

Group membership can be maintained by hand, synced from OIDC claims, or provisioned over SCIM.

Scopes sit above grants, not below

A read-only personal access token is rejected for a write operation before any grant check runs. Both must pass.

Authentication

Local password

Email and password, with sessions carried in cookies (cograph_access, cograph_refresh, cograph_csrf). Access tokens live 8 hours, refresh tokens 30 days, with token families for rotation and reuse detection. Secure is forced on in production.

Self-registration is off by default (AUTH__REGISTRATION_ENABLED). The first admin comes from the one-time bootstrap token or the CLI — see Quickstart.

Rate limits, not configurable:

  • 20 attempts per IP per 15 minutes, whatever the outcome
  • 5 failed attempts per email address per 15 minutes

Bootstrap attempts are separately rate-limited by IP so the setup token cannot be brute-forced.

OIDC

Multiple providers, configured at /admin?tab=identity-providers. Authorization code flow with PKCE, state rows expiring after 10 minutes, and client secrets encrypted at rest.

Set AUTH__EXTERNAL_URL behind a proxy

The redirect_uri must match what the provider has registered, exactly. If a reverse proxy rewrites the host, pin the public origin with COGRAPH_AUTH__EXTERNAL_URL or the callback will be rejected.

Auto-provisioning trust model. A first-time OIDC login creates an account only when one of these holds:

  • the provider asserts email_verified: true, or
  • the provider has a non-empty domain allowlist and the email's domain matches.

When a domain allowlist is set it is enforced on both paths — a verified email from outside the allowed domains is still rejected. Without either signal, an unverified email from a misconfigured provider would be enough to claim an account.

Group membership can be derived from IdP claims, and mapping changes are audited.

Personal access tokens

For the REST API and MCP. Format cgr_pat_ plus 48 random bytes, base64url.

  • Stored as a raw SHA-256 digest with no pepper. That is deliberate and documented: a 288-bit random secret is not brute-forcible, so a pepper would add key-management burden without adding security.
  • Closed scope set, enforced by a database constraint: api:read, api:write, mcp. MCP needs mcp and api:read.
  • Optional expiry; revocable, rotatable; tracks last-used time and IP.
  • Shown in plaintext exactly once.

Cookie and bearer-JWT sessions implicitly hold all scopes — only tokens are scope limited.

Mint and manage at Account → Tokens. An admin can list another user's tokens and revoke all of them at once.

Linked identities

A user can hold a local password and several OIDC identities. Unlinking is blocked when it would leave the account with no way to authenticate.

SCIM 2.0 provisioning

Mounted at /scim/v2, outside /api. Authentication is a bearer token minted by an admin at /admin?tab=scim.

A deliberately small, declared subset of RFC 7644:

ResourcesUsers only. Groups returns not-implemented.
FiltersuserName eq and externalId eq only
PATCHreplace, add, remove
Not supportedsorting, ETags, bulk, changePassword
Page size100

Attribute mapping: userName and emails[primary].value → email, name.givenName + familyName → name, active → enabled state, externalId → the identity subject for that provider.

Deprovisioning

Disabling a user over SCIM runs one transaction that: marks the account inactive with a scim reason, revokes every non-revoked personal access token, drops the refresh-token families, and writes an audit row.

Cookie sessions die at the next request, because the active flag is read per call with no per-process cache. There is no window where a disabled user keeps working.

Last-admin protection

SCIM can never disable the final active administrator. The attempt returns a SCIM 403 and records a rejected event, so the IdP side has a trace rather than a silent failure.

Replays are idempotent — events are keyed and checked before applying.

Admins can read the SCIM event log at /admin?tab=scim.

Query logs

A separate channel from the audit log: the audit log records privileged actions, query logs record what users ask Cograph. Both REST and MCP write through the same asynchronous job, so the table is the single answer to "what is this deployment used for".

FieldNotes
sourcerest or mcp
statusok, empty, or error
user_email_snapshotDenormalised at write time, so "who ran this" survives account deletion
queryTruncated to query_text_max_bytes (default 200) on a UTF-8 boundary, with a truncation flag

empty is a feature

Queries returning nothing are recorded with status empty specifically so operators can find index and wiki gaps without grepping for zero result counts. It is the cheapest signal you have for "people keep asking about X and we have nothing".

Controls:

  • COGRAPH_QUERY_LOG__DISABLED — kill switch, no redeploy needed
  • COGRAPH_QUERY_LOG__RETENTION_DAYS — default 30, enforced by a daily prune
  • Per-repository log_queries — opt out for a sensitive repository
  • DELETE /api/me/query-logs — a user can erase their own history

Admins get aggregate views at /admin?tab=query-logs: stats, per-user activity and time series.

Audit log

47 event types are recorded inside the transaction of the action they describe, so an audit row never survives a rolled-back change. Coverage includes role changes, user lifecycle, identity linking, token minting and revocation, group and grant changes, identity-provider changes, git host and credential changes, LLM role assignment, SCIM operations, and sync cancellation.

The audit log is write-only today

There is no read endpoint and no UI for audit_events — it is reachable only by querying the database directly. If audit review is a compliance requirement for you, plan for SQL access or wait for the read surface. (The SCIM event log does have an admin view.)

Secrets at rest

SecretCipher
LLM provider API keysFernet, key from AUTH__LLM_ENCRYPTION_SECRET (falls back to a JWT-derived key)
OIDC client secretsFernet, key from AUTH__OIDC_ENCRYPTION_SECRET (same fallback)
Git tokens and webhook secretsOwn cipher, JWT-derived with domain separation

Set the two independent secrets and run reencrypt-secrets so that rotating the JWT secret does not invalidate stored credentials, and a JWT leak does not compromise them. Production warns at startup while they are unset. See secret rotation.

Hardening checklist

  • [ ] COGRAPH_AUTH__JWT_SECRET — 32+ random characters, not a placeholder
  • [ ] COGRAPH_ENVIRONMENT=production — forces secure cookies, hides OpenAPI, makes Redis mandatory
  • [ ] COGRAPH_AUTH__REGISTRATION_ENABLED=false unless you want open signup
  • [ ] COGRAPH_AUTH__PUBLIC_READ left off unless anonymous browsing is intended
  • [ ] COGRAPH_AUTH__EXTERNAL_URL set if behind a proxy
  • [ ] COGRAPH_MCP__ALLOWED_HOSTS set for an internet-facing deployment
  • [ ] Both encryption secrets set, and reencrypt-secrets run
  • [ ] TLS terminated in front; PostgreSQL and Redis not publicly reachable
  • [ ] Per-repository log_queries disabled where query text is sensitive

Documents Cograph 0.1.0 · Apache-2.0 · pre-1.0, so APIs and migrations may change.