Who sees what
One function decides which repositories a principal may see, and every surface asks it: search, the file view, blame, history, the repository list, the HTTP API, MCP tool calls, analytics and the estate-wide aggregates. There is no second place a decision is made, so there is no second place to get it wrong.
This page explains how that policy is written, how it is evaluated, and how to answer "why can Bob see this?" when somebody asks.
The model in one paragraph
You put repositories into repository groups. You write bindings that grant a role over some repository groups to some identity groups — the groups your identity provider, or a local account, says a person is in — or to named people, by email address, behind an authenticating proxy. A person can see a repository when at least one binding matches one of their identity groups or their address, and grants a role over one of that repository's groups. Nothing else grants repository access, and nothing takes it away except removing the grant.
Administering the instance and reading the audit log are a separate question,
answered by authz.adminGroups and authz.auditorGroups, and by
authz.adminUsers and authz.auditorUsers for people named by address.
Repository groups
A repository group is the unit access is granted over. Declare them once:
repositoryGroups:
- name: platform
description: Shared infrastructure, readable by all engineering
- name: payments
description: Payments platform — restricted
- name: open-source
description: Mirrors of our public repositories
Every repository and directory source names at least one group, and each must be a declared one. A repository with no group is a configuration error rather than a warning, because an unclassified repository is one nobody decided the audience of. A code-host connection names the groups every repository it discovers joins, written out in the same way.
repositories:
- url: https://git.example.com/payments/ledger.git
groups: [payments]
credential: git-https
- url: https://git.example.com/platform/deploy-tools.git
groups: [platform, open-source]
Membership is always written out, never matched by pattern. A repository's
groups are explicit, which is why large estates keep them in a generated file
under conf.d/ (searchcode discover writes one for review). A rename of a
repository can therefore never silently move it into a different audience.
Labels grant nothing. labels on a repository are for facets and filtering
only. If you find yourself wanting a label to grant access, it should be a group.
Bindings
A binding grants one role, over some repository groups, to some identity groups:
authz:
defaultAccess: deny
bindings:
- name: Engineers read platform and open source
groups: [engineering]
repoGroups: [platform, open-source]
role: viewer
- name: Payments team reads payments
groups: [payments-eng]
repoGroups: [payments]
role: viewer
nameis required. It appears in the admin UI, in explain-access answers and in audit records, so write it as a sentence somebody reviewing the policy will understand.groupsare identity groups, matched exactly (surrounding spaces are trimmed; case is not folded)."*"means any authenticated principal. It never matches the anonymous principal inauth.mode: open, which has its own rule below.usersare people, by email address. A binding needsgroups,users, or both, and matches a person in either list. See the next section.repoGroupsare repository groups. Globs are allowed here and only here:repoGroups: ["team-*"]grants every declared group whose name matches.roleis a built-in or custom role.
Naming people instead of groups
Some identity providers send no groups at all. Google Workspace is the common one. Behind such a provider, name the people:
authz:
adminUsers: [first.admin@example.com, second.admin@example.com]
bindings:
- name: Everyone signed in reads the estate
groups: ["*"]
repoGroups: [engineering]
role: viewer
- name: The engagement team reads the client's repositories
users: [a.person@example.com, b.person@example.com]
repoGroups: [client-work]
role: viewer
- Proxy mode only. An entry is matched against the email address your
authenticating proxy sends in
X-Forwarded-Email, after the same header stripping and peer-address trust as groups. Withauth.mode: localoropenthe lists are refused at load: a local account carries no address, so give the person a group withsearchcode userinstead. - Matched exactly, ignoring the case of A to Z. Nothing else is folded. Dots
and
+tagsare kept, sobob+admin@example.comandbob@example.comare different people here. Write an internationalised domain in its punycode (xn--) spelling; any other non-ASCII character is refused, so a look-alike character can never match a real address. - No wildcards.
*@example.comis refused, because it grants accounts that do not exist yet. Restrict the domain in your proxy (oauth2-proxy'semail_domains), and grant everyone it admits withgroups: ["*"]. - Only as trustworthy as your proxy's email. The proxy must pass only addresses the provider has verified. The proxy recipes say how for each common provider.
- An address that is reassigned keeps its grants. If a departing person's address is later given to somebody else, the new person inherits what was granted to that address by name, as a renamed group would. Removing the entry is part of offboarding.
An agent's API key carries the address of the person who created it, so a grant
by name reaches their keys, and removing the name revokes them on the next call.
A service account never matches a users entry and is never made an
administrator by adminUsers.
Globs are expanded at load, and logged every time
A glob on the repository side means a repository group created next month falls
into a binding written today, and nobody reviewing the new group sees the binding
that picked it up. So every load and every reload logs, for each binding that
matched by pattern, which groups it matched and what it now grants — an INFO
line with the code bc920935. A binding whose identity side is "*" is logged
the same way (code 12e75518). Alert on those codes, not on the message text;
see log codes.
A binding whose repoGroups match nothing is a typo and fails validation, rather
than compiling to a grant of nothing.
Roles and permissions
There are two kinds of permission, and they do not mix.
Repository-scoped — granted by a binding, over repositories:
| Permission | What it allows |
|---|---|
search |
Searching the repository's code |
view_file |
Reading a file, its outline and navigation |
view_history |
Reading commit history |
view_blame |
Reading line-level authorship |
use_api |
Reaching the repository through the HTTP API |
use_mcp |
Reaching the repository through MCP tools |
Instance-scoped — granted by group membership, never by a binding:
| Permission | Who has it |
|---|---|
view_audit_log |
authz.adminGroups, authz.auditorGroups, authz.adminUsers, authz.auditorUsers |
view_analytics |
authz.adminGroups, authz.auditorGroups, authz.adminUsers, authz.auditorUsers |
administer |
authz.adminGroups and authz.adminUsers only |
The two built-in roles, viewer and admin, both grant every
repository-scoped permission. admin is not "more" over repositories, on
purpose: administering the instance comes from authz.adminGroups, and a role
that quietly carried it would make "this role is scoped to these repositories"
false.
A custom role composes repository-scoped permissions only:
authz:
roles:
search-only: [search, use_mcp]
Naming an instance-scoped permission in a custom role fails validation, because binding "can read the audit log" to a repository group means nothing — the audit log spans the whole estate.
Administrators and auditors
authz:
adminGroups: [platform-admins]
auditorGroups: [security-eng]
adminUsers: [break.glass@example.com] # people by address, proxy mode only
auditorUsers: [compliance@example.com]
adminUsers grants exactly what adminGroups grants, and auditorUsers
exactly what auditorGroups grants. Name at least two administrators by one
route or the other: one is a single lost laptop from none. The instance reports
how many people these lists name, on its status endpoint and at startup, and
never who.
- An administrator holds all three instance-scoped permissions: the admin screens and actions, plus everything an auditor can read.
- An auditor holds
view_audit_logandview_analytics: explain-access, the operator overview and the usage view, all read-only. An auditor can change nothing.
Neither grants any repository. An auditor who should also read code needs a
binding for the repositories they may read, like anyone else. This is what
"repository-scoped and instance-scoped are separate" means in practice: being
able to see who accessed payments does not let you read payments.
How evaluation works
Allow-only, and the answer is a union
There are no deny rules, no exceptions, no priorities and no overrides. A
principal's access is the union of every binding that matches them. That makes
evaluation independent of order, which matters because conf.d/*.yaml
fragments are merged: if order mattered, access would depend on how files sort.
The consequence is that some policies are not expressible here. "All engineers
except contractors" cannot be written as a binding. It belongs in your identity
provider, as a group such as engineering-fte, and the binding grants to that.
Nothing matched means nothing
authz.defaultAccess defaults to deny. A principal who matches no binding sees
no repositories, and an empty answer is the safe failure for a forgotten grant.
Setting defaultAccess: allow gives a principal who matches no binding the
viewer role over every declared repository group. It applies only where nothing
else did, never to the anonymous principal, and the instance logs a warning on
every load while it is set.
Filtering happens before the query, not after it
The set of repositories a principal may see is computed first and carried into the query, so a repository outside it is never read. Nothing is fetched and then filtered out. That is why result counts, facet counts and response times do not reveal repositories you cannot see.
A repository you cannot see does not exist
Asking for a repository you may not see answers 404, the same as one that does not exist. A 403 would confirm it exists, which leaks the shape of the estate to anyone who can guess a name. A 403 is used only where you can see the target and lack a specific permission — explain-access is the usual case.
Service accounts
A service account is a principal for an application, declared in configuration
and never created in the UI. Its groups are repository groups, named
directly:
serviceAccounts:
- name: release-bot
groups: [platform] # repository groups, not identity groups
role: viewer
tokenFile: /run/secrets/release-bot-token
- It never matches a binding. Its groups are a different vocabulary from a binding's identity groups, and matching one against the other would grant access wherever the two happened to share a word.
- Globs are refused, and so is a group the instance does not declare.
- Its groups are read live from the configuration, so narrowing or deleting one takes effect on the next request after the reload.
How to create, rotate and revoke one is in credentials.
Open mode's anonymous principal
auth.mode: open has no sign-in, and everybody is the anonymous principal. It is
still evaluated by the same policy: an implicit binding grants
authz.anonymousRole over every declared repository group. The role defaults to
viewer and is never admin, and the setting is refused in any other mode. The
instance logs a warning on every load naming the role and the groups it covers.
There is no switch anywhere that skips authorisation in open mode. Open mode changes who the principal is, not whether they are checked.
When a change takes effect
The authz block, repositoryGroups, repository group membership and
serviceAccounts all reload without a restart. The policy is compiled once per
load. A change that does not validate is rejected, the previous policy stays in
force, and the rejection is logged and audited as config.reload_failed.
What is never cached is the answer. Every request is evaluated against the policy in force at that moment, so a removed grant stops working on the next request rather than when some cache expires.
How identity groups reach the policy depends on the credential:
| Credential | Identity groups used | Bindings used |
|---|---|---|
| A browser session | As the proxy or local account states them now | Live |
| An OAuth token for an MCP client | Re-resolved on every refresh (behind a proxy, from the last time the person signed in through it in a browser) | Live |
| A personal API key | Frozen at the moment the key was issued | Live |
| A service account | Its configured repository groups, read live | Its own grant, live |
The personal-key row reads as inconsistent and both halves are deliberate. In proxy mode the request that issued the key is the only place the person's groups existed, so they are recorded with it. A policy edit therefore affects an existing key on its next call, while a change to someone's groups in your identity provider does not reach a key they already hold. Revoke and reissue, or let the key's lifetime run out — which is why key lifetimes default short. See credentials for lifetimes and offboarding.
Why can Bob see this?
Administrators and auditors can ask the instance, and the answer names the bindings that granted access, or says that none did.
In the UI: the admin area's explain-access screen, at /admin/explain.
Over the API:
GET /api/v1/explain-access?repo=ledger&user=bob
| Parameter | Meaning |
|---|---|
repo |
The repository to ask about. Required. |
user |
Whose access to explain: a subject, a username or an email address. Omit it for your own. |
groups |
Comma-separated identity groups to evaluate instead of the subject's — the "what if they were in payments-eng" question. |
permission |
Which repository-scoped permission to explain. Defaults to search. |
The answer carries allow, a human-readable reason, the groups it was
computed from, the repository's repo_groups, and matched: one entry per
grant, naming the binding, the identity group that matched (or "*") or, for a
grant by name, the users entry that matched (via_user), the repository group that carried the grant, whether that group was named literally
or by which glob, the role, and the source — declarative for a rule somebody
wrote, anonymous, default or service for one the product supplied.
Where Bob's groups come from depends on the mode. With local accounts, the
account record is authoritative. Behind a proxy, a person's groups arrive on each
request and are stored nowhere authoritative, so the answer uses the last groups
this instance saw for them; pass groups to ask about a specific set. A name
the instance has never seen is answered as a principal with no groups, which can
see nothing. An email address it has never seen is answered as that address, so
you can check what somebody will get through users before they first sign
in.
When the repository is not in the catalogue because a code-host connection
retired it, the answer says so in notices, with the reason, rather than
leaving you to hunt for a typo.
Both outcomes are audited, the refusal included: authz.explain_access when
it answers and authz.explain_refused when the caller is neither an
administrator nor an auditor. Who has been asking who can see what is itself an
audit question. See the audit log.
Checking a policy before it ships
searchcode config validate checks a configuration file without starting the
instance: unknown roles, an instance-scoped permission in a custom role, a
binding that matches no repository group, an undeclared group on a repository or
service account, and an anonymous role outside open mode all fail it. Run it in
CI on the change that edits the policy, so a mistake fails review rather than
reaching the running instance.
What this product does not do
It does not read permissions from your code host. A connection to GitHub or GitLab contributes repositories and nothing else, and nothing here mirrors your code host's access-control lists. Access comes from the bindings you write and review, in a file you can keep in git. See trust for the rest of the security model and the questionnaire for the short answers a review asks for.