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

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

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.

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

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.

Generated from the documentation that ships with searchcode enterprise. Questions: ben@boyter.org