Skip to content
GitRiverGitRiver
RU
Navigation

Administrator Guide

Administrator guide: configuring an installation, users and groups, directory sign-in, quotas, the audit log and routine maintenance

Admin panel

Available at /admin for users with the administrator role.


User management

Creating a user

API: POST /api/v1/auth/register

In the UI: users register themselves. The administrator can manage existing users.

Closed registration

The “Registration enabled” toggle (Administration → System, PUT /api/v1/admin/system) closes new account creation across all sign-in methods at once:

Method What happens when registration is closed
Registration form, POST /api/v1/auth/register 403 refusal, code registration_closed
First sign-in via OAuth2 sign-in succeeds at the provider, but no account is created; 403 refusal
First sign-in via SAML same; server log shows check=registration
First sign-in via LDAP/AD same; sign-in returns a refusal, not “wrong password”

The refusal is distinguished from a permission refusal by the machine-readable code registration_closed — the client doesn’t need to parse the translated text.

For those who already have an account, the setting doesn’t get in the way at all: it governs creating new accounts, not signing in. It also doesn’t affect two administrative paths — external management via SCIM and the initial setup wizard: there, accounts are created by someone other than the person signing in.

The “Auto-registration” flag on an OAuth or SAML provider remains a second condition: an account is created only when both are allowed. Turning it off for one provider closes auto-registration selectively, without touching the other sign-in methods.

Directory sign-in with registration closed. LDAP has no “Auto-registration” flag, so the global toggle is the only lever: closing registration also closes account creation from the directory. If you need employees to sign in via the directory without being able to register themselves, create the accounts in advance — through external SCIM management or manually — and enable “Link existing local accounts” in the LDAP settings; otherwise the directory won’t link them to itself.

Closing sign-in, deactivation, and deletion

Three different measures against an account — each with its own purpose:

Measure Who controls it What happens Reversible
Closing sign-in (Administration → Users → lock icon, PATCH /api/v1/users/{username} with is_blocked) instance administrator all sign-in methods close at once: password, external providers, personal access tokens, SSH keys; issued tokens are revoked; this author’s scheduled builds stop with the reason creator_blocked yes, with the same toggle
Deactivation (active=false via SCIM) HR system the same sign-in closure, but the decision belongs to the external system yes, from the HR system
Deletion (DELETE /api/v1/users/{username}) instance administrator the account, its tokens, and its keys disappear; forbidden while the user still owns repositories no

Closing sign-in is a temporary measure: a leave of absence, suspected password theft, a security review, a contractor leaving while their history is kept. Data, authorship, and group membership stay in place; content isn’t hidden.

Two restrictions, both there to avoid ending up unable to manage the instance: you can’t close your own sign-in, and you can’t close it for the last active administrator — there would be no one left to lift the block afterward.

Closing and restoring sign-in are recorded in the audit log as the events block_user and unblock_user, naming the account the measure was applied to.

A build already running for a blocked author is allowed to finish: the job token belongs to the job, not to the person. New runs are closed off along with sign-in.

Management

Action API
List users GET /api/v1/users
Details GET /api/v1/users/{username}
Edit PATCH /api/v1/users/{username}
Close or restore sign-in PATCH /api/v1/users/{username} with is_blocked
Reset password PUT /api/v1/users/{username}/password
Delete DELETE /api/v1/users/{username}
Rename POST /api/v1/users/{username}/rename

Renaming a user

POST /api/v1/users/{username}/rename with the body {"username": "new-name"}. Permissions: the user themselves or an administrator.

Renaming is a separate call rather than a field on PATCH /api/v1/users/{username}, and it’s recorded in the audit log.

What happens:

  • the user’s namespace is renamed together with the username: repository addresses become /{new-name}/{repo}, and the repository and wiki directories on disk move under the new name. The package registry, LFS, artifacts, and already-published Pages sites keep working with no reconfiguration;
  • the old name is freed, but until someone takes it, it redirects to the new owner. git clone/fetch/push, Git LFS, package registries, published Pages sites, and the whole REST API keep working under the old name — updating git remote and external links isn’t required, though it’s advisable.

Restrictions and consequences:

  • the new name is checked by the same rules as at registration, including the list of names taken by application routes (admin, settings, explore, api, and others);
  • as soon as another user or group claims the freed name, the redirect disappears, and links to the old name now lead to them;
  • you can reclaim your former name as long as no one else has taken it;
  • if the rename fails, the old name stays in place — entirely, along with its addresses and directories on disk.

Usernames and group names share one namespace, so a name taken by a group is unavailable to a user and vice versa.

Profile visibility

By default, user information is visible only to those who are signed in: an anonymous visitor can’t learn who works on the instance and on what.

The setting closes ALL paths to this information at once: the profile card by name, the profile card by ID, and the user listing with search. Closing just one of them makes no sense — the listing answers the same “who works here” question, and on top of that shows who among them is an administrator.

A public instance (open-source code, a project showcase) enables visibility with a setting:

public_profiles = true

or via the environment variable: GITRIVER_PUBLIC_PROFILES=true.

The setting behaves the same on the main /api/v1 interface and on the /api/v3 compatibility layer: it can’t be bypassed through the compatibility layer. It doesn’t disclose the email address: that remains visible only to the owner themselves and to administrators.

“All paths” also includes side addresses tied to a name: a user’s repository list, their stars, their event feed, their activity heatmap, their GPG keys, and the /api/v1/namespaces/{name} lookup for whether a name is taken. Each of them answers the same “who works here” question, and leaving any one of them open defeats the setting: account names can be enumerated by a yes/no signal.

The current value is publicly available at /api/v1/server-info. With profiles closed, the “Users” menu item isn’t shown to guests.

User and group avatars are derived from the name and served at /avatars/{name}.svg without authentication. An avatar is generated for any name, so it can’t reveal whether a given name exists.

What the setting does NOT close — the author byline on a record. An issue, comment, pull request, review, and release all carry their author’s card (author: account name, display name, ID) right in the response, and anyone who can see the record on a public repository sees it. You can’t enumerate the instance’s roster through it: the name only arrives attached to a record the viewer is already reading. The compatibility layers respond the same way. Email address and paid-seat status don’t appear in the card.

Unavailable is indistinguishable from nonexistent

A repository, a group, or any of their nested objects, when unreachable to the requester, respond with 404 — the same code and body as a nonexistent address. A separate 403 would confirm the object exists, and enumerating names (and in the compatibility layers, also numbers — /api/v4/projects/1..N) would reveal the list of the instance’s closed projects and namespaces.

The rule is the same for /api/v1, /api/v3, and /api/v4.

403 remains where the requester CAN SEE the object but lacks permission for the action itself: writing to an archived repository, a token missing the needed scope, an edition or quota limit — the person needs to understand what to do next.

The real reason for a refusal is written to the server log at debug level: that’s how a closed object is told apart from a typo in the address.

Git over HTTP, LFS, and the container registry follow the same rule, but the concealment is mirrored: a client that hasn’t identified itself yet gets 401 with WWW-Authenticate in both cases, not 404. Neither git nor docker presents credentials on a 404 — a closed repository would become impossible to clone or pull an image from. A client that has already identified itself gets 404 in both cases. Over SSH, a read refusal gives the same message as a missing repository.

A public repository isn’t hidden: anyone can read it, and a write refusal for it names the reason directly — for example, a CI token from a different job.


Groups (organizations)

Groups bring together users and repositories.

Action API
Create a group POST /api/v1/groups
List groups GET /api/v1/groups
Rename a group POST /api/v1/groups/{path}/rename
Add a member POST /api/v1/groups/{path}/members
Change a member’s role PATCH /api/v1/groups/{path}/members/{user_id}
Remove a member DELETE /api/v1/groups/{path}/members/{user_id}
Custom roles POST /api/v1/groups/{path}/custom_roles

A member’s role changes with a single request and a single selection in the member list: the join date is preserved, and the group is never left without a member in between. The same rules apply as when adding a member: managing the roster is a privilege of maintainer and above, you can’t assign a role higher than your own, and you can’t act on a member senior to you either.

A group is never left without an owner. Neither removing the last owner nor demoting them goes through. “Last” is counted among those who CAN manage the group: an owner whose sign-in is closed (blocked by an administrator or deactivated by the HR system) doesn’t count — otherwise the group would remain formally owned but effectively unmanaged.

A group’s path changes via POST /api/v1/groups/{path}/rename with the body {"path": "new-path"} — requires group administrator permissions. The semantics are the same as for renaming a user (see above), including the redirect from the old path. The group’s display name (name) is a separate field; SCIM, among others, changes it, and it doesn’t affect the path.

Custom roles

Let you create roles with fine-grained permissions for group members.

The full list of permissions, in domain:action format, is served by GET /api/v1/permissions — the same list the UI shows when configuring a role. The list changes with releases, so only this endpoint gives the current one.

Custom roles and this list are a Pro edition feature, and they’re metered by SEATS: whoever holds a seat configures the group’s roles (see licensing). In Community, a member’s permissions are set by their repository access level: read, write, administer.


Quotas

Limiting resources for users and groups.

Action API
Global quotas GET/PUT /api/v1/admin/quotas
List of users with usage GET /api/v1/admin/quotas/users
User quotas GET/PUT /api/v1/admin/quotas/users/{id}
List of groups with usage GET /api/v1/admin/quotas/groups
Group quotas GET/PUT /api/v1/admin/quotas/groups/{id}

A quota value of zero means “unlimited,” not “nothing allowed.”

A personal quota replaces the defaults entirely. Setting your own limits for a user or group sets ALL of them: an unfilled limit in a personal record is read as zero — that is, “unlimited” — not “fall back to the system value.” So when you limit one category, fill in the others too — otherwise they end up lifted. For the same reason, a limit added in a newer version (release attachments, CI cache) doesn’t start applying right away for owners who already have a quota set: it’s empty for them, and a version upgrade doesn’t limit anything until the quota is saved again.

Quota summary lists are paginated

GET /api/v1/admin/quotas/users and GET /api/v1/admin/quotas/groups return a page, not the whole list:

GET /api/v1/admin/quotas/users?per_page=20&after=<cursor>
GET /api/v1/admin/quotas/groups?per_page=20&after=<cursor>

The response follows the same shape as other cursor-paginated lists: {"items": [...], "next_cursor": "..."}. Ordered by owner name; next_cursor is empty once the list is exhausted. In the admin UI, pages load with a button at the end of the list.

Upgrading from 1.0.x. In versions up to and including 1.0.x, both calls returned a top-level array — this is a breaking change. A client reading the response as an array needs to switch to the items field and page through with next_cursor.

Whose quota is spent

A repository belongs either to a user’s personal namespace or to a group — and spends the quota of whichever it belongs to:

  • a repository outside a group — the owner’s quota;
  • a repository in a group — the group’s quota, not the creator’s personal quota.

The rule is the same for every category: LFS, Pages, CI artifacts, CI cache, release attachments, and package storage. Usage is shown wherever the limit applies: a group’s repositories count toward the group’s “used” figure and not toward its members’ personal “used” figures. When a repository moves between an owner and a group, its usage moves with it.

Subgroups are counted separately. Unlike membership and CI variables, quotas don’t inherit down through subgroups: a subgroup’s repository spends its own subgroup’s quota, not the ancestor group’s, and doesn’t count toward the ancestor’s “used” figure. To limit a whole branch, set a limit on each subgroup.

On upgrade. After the upgrade, group repositories spend the group’s quota, not the creator’s personal quota; if the group has no quota set, system default limits apply. If you have nonzero default limits set, check the groups on the “Quotas → Groups” tab: a single limit shared by the whole group may turn out tighter than the personal limits of its members.

When each limit is checked

Limits aren’t recalculated on a schedule — each one is checked at the moment the resource is requested. Zero in any of them still means “unlimited.”

Limit When it’s checked What happens when it’s exhausted
Repository count (max_repos_count) creating a repository, forking, importing from an external service, transferring to someone else’s namespace 403, the repository isn’t created
Repository size (max_repo_size) before accepting a git push (across all transports: HTTP, SSH, gitriver serv), before a fork, before a transfer to someone else’s namespace, before mirror sync; after an import or fork the size is recalculated from the actual data the push is rejected entirely and git prints the reason; a fork or import gets 403 and the created repository is rolled back
LFS (max_lfs_size) uploading an LFS object — before the body is accepted, based on the declared size the upload is rejected (403), no space is used
Pages (max_pages_size) publishing a site — manual and automatic from CI; extraction is aborted once the remaining budget runs out manual publishing is rejected (403); automatic publishing doesn’t create a deployment, the reason goes to the server log (the pipeline stays successful)
CI artifacts (max_ci_artifacts) starting a pipeline AND saving the artifact archive: an external runner’s upload is aborted once the remaining budget runs out, the built-in runner checks before packing and again on the result. A repeated run of a job replaces its previous archive, and the replacement counts as growth the pipeline doesn’t start; the upload is rejected (403), no archive remains on disk. With the built-in runner the job FAILS (artifacts were declared and dependent jobs are waiting for them), and the job log shows “Collecting artifacts: the archive was not saved on the server. Quota exceeded: …”
CI cache (max_ci_cache) saving a job’s cache — an external runner’s upload is aborted once the remaining budget runs out, the built-in runner checks before packing and again on the result. Replacing the archive under an existing key counts as growth, and a non-growing replacement goes through even with the quota exhausted the cache isn’t saved, the job log shows “Saving cache: the archive was not saved on the server. Quota exceeded: …”; the job stays successful, the previous archive under that key is intact
Release attachments (max_release_attachments_size) uploading an attachment — before the file is written to storage the upload is rejected (403), no space is used
Packages (max_packages_size) writing to package storage see “Package storage quota”

Repository size is a limit on a SINGLE repository, not their sum. It’s compared against the size of the repository the push is going into (recalculated after every push), so an owner with a 1 GB limit can have ten repositories of 900 MB each. The owner’s total git data volume has no separate limit of its own: it’s shown in the quota view as a reference figure.

The check happens before the push data is accepted, based on the size known at the start of the push. A consequence worth knowing: a push that pushes the repository over the limit is accepted in full, and it’s the next one that gets rejected.

A push that only deletes branches and tags is never checked: that way an owner past the limit can always free up space.

Transferring a repository spends the receiving side’s limit. A transfer to a user or group is checked the same way as creation.

Database unavailability doesn’t lift the limits. If the database is unreachable, an operation subject to a quota is rejected, not allowed.

Space is reserved for the duration of an upload

An upload reserves space as soon as it starts, not when it finishes: so concurrent uploads can’t jointly exceed the limit.

What follows from this for an administrator:

  • concurrent uploads from the same owner split the remaining budget between them. If 500 MB is free and two 400 MB uploads are running, the second one gets 403 — even if the first one ends up smaller than declared;
  • an upload without a declared length reserves its whole per-file limit. A client that didn’t send Content-Length (Transfer-Encoding: chunked) is treated as the largest possible size until it finishes;
  • an aborted connection frees the space right away, and if the server was restarted mid-upload — no later than the upload request’s deadline expires (upload_http_timeout_secs, one hour by default). No manual cleanup is needed;
  • usage reports show what’s completed, not uploads in progress: “Quotas → Users/Groups” and GET /api/v1/admin/quotas/... report disk actually used, not promises of an upload to come. The one exception is unfinished image layer uploads: the bytes they’ve already accepted are already on disk and count toward usage (see “Package storage quota”).

An upload is aborted by whichever is SMALLER of two limits — the remaining quota or the per-file limit. Which one triggered is visible from the response code: 403 — space ran out (fixed with the quota), 413 — the file exceeds the limit (fixed by the uploader).

The cost of a set limit. As long as a limit isn’t set (zero — “unlimited”), usage isn’t counted at all. Once a limit is set, every upload recounts the space the owner occupies in package storage, and concurrent uploads from the same owner go through this check one at a time. For a typical docker push that’s a fraction of a second, but for an owner with hundreds of thousands of layers accepting uploads slows down noticeably. If you don’t need a limit, don’t set one: a zero value not only allows everything, it also counts nothing.

Release attachment quota

Files published with a release are their own usage category (max_release_attachments_size), not part of “packages.” They’re counted differently, and the two shouldn’t be conflated: package storage deduplicates content (an identical layer or file occupies space once), while an attachment is stored as its own file, and the same build published in two releases occupies disk twice — exactly how the quota counts it.

Where usage is shown:

  • the owner — “Settings → Storage Usage,” the “Release attachments” row;
  • the administrator — “Quotas → Users/Groups,” the same row next to the limit, and GET /api/v1/admin/quotas/users/{id} (the usage.release_attachments_size field);
  • the limit itself — limits.max_release_attachments_size in the same place.

When it’s checked. On uploading an attachment, BEFORE the file lands in storage. If the client declared the part’s length (Content-Length), the refusal arrives before the body is accepted; if it didn’t, the upload is aborted once the remaining quota runs out, and what was accepted stays in a temporary directory and is cleared along with the refusal. Used space doesn’t grow after a refusal either way.

The refusal is 403 with the used volume, the limit, and the remainder. Don’t confuse it with 413 “file exceeds the limit”: the per-file limit is 500 MiB, it isn’t changed by settings (see file upload limits) and is fixed by the uploader, while 403 is fixed by the quota.

Freeing space happens by deleting an attachment or an entire release: usage is counted from the current state, there’s no separate recalculation.

On upgrade. Attachment usage becomes visible right after the upgrade, while the limit stays at zero — “unlimited” — until you set it. Start by looking at the real numbers: on installs with large release builds this row is often the largest.

Package storage quota

Image layers, package files (npm, PyPI, Cargo, Maven, NuGet, Generic, Composer), and the dependency proxy cache live in one shared store and are counted together. Identical content is stored once and counted once toward the total — no matter how many references point to it.

Two limits apply, and they act simultaneously:

  • the owner’s quota — the “packages” category, shared across all their repositories (for a group repository this is the group’s quota, see “Whose quota is spent”);
  • the repository’s quota, registry_quota_bytes — GET /api/v1/repos/{owner}/{name}/packages/usage.

When either one is exhausted, all write paths are rejected: publishing a package, accepting an image layer (whole, in parts, or transferred from another repository), and populating the dependency proxy cache. The refusal is 403 naming the used volume and the limit.

An unfinished layer upload reserves space right away. docker push sends a large layer as a hundred consecutive requests, and what each of them accepts is already on disk: that’s why usage includes an upload that hasn’t finished yet. The quota refusal arrives on whichever request exhausts the limit, not on completion — and what that upload accepted is cleared along with the refusal. Uploads abandoned midway are picked up by the registry’s automatic cleanup.

The quota limits the total volume; the size of a SINGLE request is limited separately and isn’t changed by settings — see file upload limits. Their refusals differ both in meaning and in code: 413 — “file exceeds the limit,” fixed by the file; 403 — “space ran out,” fixed by the quota.

Immutability of a published version

A package version’s published file can’t be replaced with different content. A repeat publish of the same version responds with 409 and leaves the prior content in place; this can’t be changed by a setting — the rule always applies, across every registry (npm, PyPI, Cargo, Maven, NuGet, Generic, Composer).

So a build that passed review will get the same code under the same version number tomorrow too.

What a version declares about itself is also immutable: description, dependency list, environment requirements — the client chooses what to install based on these. A repeat that passes as a byte-for-byte match doesn’t rewrite the version’s metadata.

What remains allowed:

  • a byte-for-byte repeat — the same version with the same content is accepted and responds 200 instead of 201, so a request can safely be repeated after a dropped connection;
  • a second file for the same version — the rule looks at the file, not the whole version number: a wheel alongside the source distribution in PyPI, and sources.jar alongside jar in Maven, publish as usual;
  • Maven snapshot versions (-SNAPSHOT) — a repeated mvn deploy of the same version is accepted;
  • yanking and deleting a version — these change availability, not content. Yanking removes a version from what’s installable: the package card and search (npm, cargo, NuGet) from that moment call the largest of the remaining versions “latest” — exactly the one a client would install. The file itself remains reachable by its exact version number, and undoing the yank restores it to the card as well.

Removing an already-published version to free up its number can only be done by deletion — the repository’s Packages tab or DELETE /api/v1/repos/{owner}/{name}/pkg/{type}/{package}/{version}. This is a deliberate action requiring write access, not a side effect of a repeat publish.


Language-model assistant

Analyzing failed CI jobs and commenting on pull request changes with a language model — your own model inside your network or a cloud provider, the administrator’s choice. Setting: Administration → AI Assistant. Entirely a Pro edition feature: both the settings and the calls. The settings aren’t metered by seats, but calls to the model beyond the edition require a seat from whoever invokes them.

An unconfigured or disabled assistant makes not a single outgoing request — this matters for a closed network. Model usage is counted against the repository owner and capped by a monthly limit (0 — unlimited), the same way storage quotas are.

Details — ai-assistant.md.


Container registry

Built-in Docker Registry V2 — every repository gets its own registry.

Usage

# Login
docker login git.example.com

# Push
docker tag myapp:latest git.example.com/owner/repo/myapp:latest
docker push git.example.com/owner/repo/myapp:latest

# Pull
docker pull git.example.com/owner/repo/myapp:latest

Management

  • Tag protection rules — prevent overwriting a tag by mask
  • Retention rules — automatic removal of old tags
  • Cleanup — manual garbage collection run
  • Vulnerability scanning — via Trivy

Registry auto-cleanup

The cleanup pass is the same one whether triggered by the schedule or the button: for each repository, the instance’s global limits apply first, then the repository’s own retention rules, then cleanup. Global limits are set in the settings (“Keep the last N tags” and an age threshold), repository rules are set by its owner — see tag retention rules.

Both use the same “delete this tag or not” decision: a tag is deleted only if it is BOTH past keep_last AND older than max_age_days. Zero in a field means “no limit”; two zeros means the rule deletes nothing.

Zeroed-out global limits don’t turn cleanup off entirely: the repositories’ own retention rules keep working, as does cleanup itself. Turning off the “Enable automatic cleanup” switch doesn’t turn everything off either — it only removes the run ON A SCHEDULE, while the “Run now” button still works with it off, using the same pass. To stop tags from being deleted, remove the retention rules on the repositories themselves: no instance-level toggle overrides them.

As of version 1.1.0, a REPOSITORY rule with both numbers set — “keep the last N” and a maximum age — deletes a tag only if it is both outside the last N AND older than the maximum age. If you have both numbers set, fewer tags will be deleted after the upgrade: some images that used to be deleted by age now stay, because they fall within the last N. This causes no data loss — the rule only preserves what used to be deleted — but if it was configured expecting the old behavior, space will free up more slowly. You can restore the old age-based cleanup by zeroing out keep_last in the rule.

Cleanup and concurrent publishing

Cleanup (POST /api/v1/repos/{owner}/{name}/packages/gc, the “GC” button, and the scheduled run) can be started at any time: a docker push in progress right now isn’t affected by it.

docker push is two requests: first layers are uploaded, then the manifest is sent. Between them, a layer already belongs to the repository but isn’t named by any manifest yet. Cleanup doesn’t touch such layers — the attachment, like the manifest of an unfinished publish, gets a day’s grace; once it passes, a layer no manifest has claimed is detached and deleted. The registry won’t accept a manifest that names a layer the repository doesn’t have: the response is 404 MANIFEST_BLOB_UNKNOWN, after which the client re-uploads the layer. Cleanup never leaves behind an image that’s in the registry but can’t be pulled.

This is also where the quota-return delay comes from. Deleting an image published more than a day ago frees its layers right away; for an image less than a day old, layers stay reserved until that same period expires — they may still belong to a publish that’s happening right now. The quota tells the truth the whole time: the bytes really do occupy space until they’re deleted.

Scanning images for vulnerabilities

The scanner runs two ways, and the settings for each differ:

  • automatically on manifest publish — only when registry_scan_enabled = true;
  • on demand — the “Scan” button in the image list, or POST /api/v1/repos/{owner}/{name}/packages/scans with the body {"image": "myapp", "reference": "1.2.3"}. Requires write access to the registry. The registry_scan_enabled flag doesn’t affect a manual run: the administrator is asking for the check explicitly.

The reference (reference) is a tag or sha256:…; a tag is resolved to a digest before the scanner starts, so the report always relates to the image that was actually requested, even if the tag has since moved.

Trivy fetches the image from the instance’s own registry with the requester’s permissions, so both private images and images pulled through the proxy get scanned. The path to the executable is trivy_path (looks for trivy in PATH by default).

Scanning an image pulled through the proxy downloads all of its layers into the repository’s cache — they consume registry quota the same way published images do.

The result is stored per digest — one record per image, regardless of how many tags point to it or how many times it’s been requested:

  • while a scan of an image is running, a repeated run against the same digest returns the one already in progress rather than starting a second check;
  • the Trivy process gets 30 minutes; one that doesn’t finish in time is terminated, and the scan is marked as an error naming the timeout;
  • a scan that’s shown no sign of life for more than 35 minutes (the scan timeout plus a margin) is considered aborted: this is what it looks like when the server running it was stopped mid-work. It’s shown as an error rather than a permanent “scanning,” and the digest can be checked again. You can re-scan a finished image any time you like — the vulnerability database keeps growing, and yesterday’s report isn’t grounds for a refusal.

Results are read through two different endpoints, and the distinction matters to anyone calling the API directly:

  • GET /api/v1/repos/{owner}/{name}/packages/scans — summaries: status, digest, severity counts. There’s no full Trivy report in them. Without parameters it returns summaries for every image in the repository; ?digests=sha256:…,sha256:… restricts the output to the listed images (up to 100 at a time);
  • GET /api/v1/repos/{owner}/{name}/packages/scans/{digest} — the same summary plus the full Trivy report (full_report_json) for one image.

Image signatures (Cosign)

A cosign sign signature lands in the registry through an ordinary push, as a sha256-<hex>.sig tag next to the signed image. The “Packages” tab marks such images with a “Signed” badge; the badge means exactly that the signature tag exists, not that the signature has been verified — for verification, go to cosign verify.

There are two endpoints here too:

  • GET /api/v1/repos/{owner}/{name}/packages/signatures?digests=sha256:…,sha256:… — signed images among the listed digests (up to 100 at a time). The parameter is required. The response contains only signed images, as image_name + digest pairs: a signature sits next to its image, and the same manifest under two images of the repository can be signed under only one of them;
  • GET /api/v1/repos/{owner}/{name}/packages/signature?image=…&digest=… — the same, for one image.

Both endpoints, like the rest of the tab, are available to anyone who can read the repository — including anonymous users on a public repository. The same rule governs GET …/packages/sbom?image=…&digest=… — whether an SBOM exists (a sha256-<hex>.sbom tag), as published by Trivy and Syft.

External image registry mirror

A repository can work as a caching mirror of Docker Hub, quay.io, registry.k8s.io, or a private registry: Repository Settings / Packages / Serve packages through the proxy, registry type docker, address — the registry root without /v2 (https://registry-1.docker.io).

docker pull git.example.com/owner/mirror/library/nginx:1.25

What this gives an administrator:

  • builds stop depending on the availability and rate limits of the external registry: once warmed up, an image is served from cache, and if the source is down, a manifest lookup by tag is served stale instead of failing;
  • layers live in shared storage alongside local images: deduplication is free, cleanup accounts for them, and the quota counts them along with everything else;
  • allow/deny masks restrict which images are allowed out at all;
  • a request without sign-in never goes out: outsiders can’t use the instance as a free mirror of the external registry;
  • images pulled through the proxy are not included in automatic Trivy scanning. Automatic scanning covers images published to the repository; a proxy-sourced image is scanned on demand — the “Scan” button with its reference specified.

Credentials for a private source are set as a username:password pair and go only to that source’s token-issuing service; the issued token is reused until it expires, so the external registry’s rate limit is spent on downloads, not on authentication.


LDAP

Enterprise authentication via LDAP/Active Directory.

UI setting: Administration / LDAP

Action API
Get settings GET /api/v1/admin/ldap
Save PUT /api/v1/admin/ldap
Test connection POST /api/v1/admin/ldap/test
Delete DELETE /api/v1/admin/ldap

The “Link existing local accounts” setting is off by default: signing in through the directory doesn’t claim a local account with the same name — see “Linking external sign-in to an account”.

Creating accounts from the directory follows the general registration toggle — see “Closed registration”.

Directory login names are normalized to GitRiver’s rules

A directory login name doesn’t follow GitRiver’s rules: dots are common there (ivan.petrov), as are spaces and non-Latin scripts, and admin is no harder to create than any other name. On first sign-in, the login name is normalized to GitRiver’s naming rules — the same way as userName in SCIM (see “Usernames are normalized to GitRiver’s rules”):

Directory login name Name in GitRiver
a_orlova a_orlova
ivan.petrov ivan_petrov
admin (taken by a route) ldap_admin

If the normalized name is taken, a numeric suffix is appended.

The link to the directory is kept by DN and login name, not by account name. DN survives a login-name change, and the login name survives a record being moved between organizational units; neither one creates a second account. Records created through the directory before version 1.1.0 are linked the same way on first sign-in — nothing needs to be done.

Different login names that normalize to the same name (“ivan.petrov” and “ivan petrov” both produce ivan_petrov) get different accounts: the second one is given a free name (ivan_petrov1), and the other account isn’t touched.

Channel encryption

URL scheme What happens
ldaps://host:636 TLS from the first byte, the certificate is always checked. There’s no toggle to disable checking
ldap://host:389 The connection is upgraded to TLS via the StartTLS extended operation — enabled by default

A directory server without StartTLS support gets a refusal, and the connection isn’t established: “couldn’t encrypt” doesn’t mean “continue in plain text.” Certificate verification is mandatory here too.

Trust is taken from the system’s root certificate store on the machine where GitRiver runs (in a container — from its image). A directory with a certificate from an internal certificate authority requires that authority’s root to be added to that machine’s store — otherwise the connection test responds with a certificate refusal. This holds equally for ldaps:// and for StartTLS.

Storing the service account password

The service account password is stored in the database, encrypted with a key derived from jwt_secret — like TOTP secrets, OAuth provider client_secret values, and the id_token signing key. It’s never returned: the settings page shows only the fact that a password is set.

This carries the same operational rule as the signing key: the directory password can’t be read after jwt_secret changes — at the next startup the server will say so in the log, and directory sign-in will turn off until the password is entered again. Restoring the database from a backup together with .jwt_secret restores everything as it was.

The same applies to the SMTP password.

The “Upgrade the connection to TLS (StartTLS)” toggle removes encryption — then the service account password and user passwords travel over the network in plain text. Leaving it that way only makes sense in a closed network; the server warns about this choice in the log at every startup and whenever settings are saved.

Directory settings are stored in the database and survive a restart; the [ldap] section in config.toml serves as a fallback source when the database has no settings.


SMTP (email notifications)

UI setting: Administration / SMTP

Action API
Get settings GET /api/v1/admin/smtp
Save PUT /api/v1/admin/smtp
Test send POST /api/v1/admin/smtp/test
Delete DELETE /api/v1/admin/smtp

“Max” notification channel

UI setting: Repository / Settings / Notification channels, channel type Max.

Channel fields:

Field Value
Bot token The token issued when the bot was created in the Max messenger. It’s sent as an HTTP header value, so only visible Latin characters, digits, and symbols are allowed — a token containing Cyrillic characters or spaces is rejected on save
Recipient A chat/channel (chat_id) or a user (user_id)
ID Integer. The Bot API addresses a message only by a numeric ID

Channel behavior notes:

  • Requests go to platform-api2.max.ru — the former domain platform-api.max.ru was decommissioned on 2026-07-19.
  • The Bot API certificate is issued by the Russian Ministry of Digital Development’s CA. The “Russian Trusted Root CA” root ships with GitRiver and applies only to requests for this channel: all other outbound GitRiver traffic is verified against the standard root store.
  • Text longer than 4000 characters is truncated — this is a Bot API limit.
  • Send rate is held at two messages per second per recipient (a Bot API limit), and 429 refusals and service failures are retried up to three times with increasing backoff.

Environment variables (needed only for non-standard installs):

Variable Purpose
GITRIVER_MAX_API_BASE An alternate Bot API address — a test environment or a proxy inside your network. Defaults to https://platform-api2.max.ru
GITRIVER_MAX_CA_BUNDLE Path to a PEM bundle of trusted roots to use instead of the built-in certificate — if the Ministry issues a new root ahead of the next release

OAuth2 / SAML SSO

Linking external sign-in to an account

External sign-in claims an existing account through exactly two paths, both described here. A matching email address isn’t enough by itself: the address comes from the provider, and ownership of it is unconfirmed.

Path one — linking by the owner. The user signs in to GitRiver, opens Settings / External sign-ins, and clicks “Link.” The session itself is proof of ownership. Works for both OAuth and SAML, and requires no configuration. If linking fails (for example, this external account is already linked to another account), the user is returned to the same page with the reason, and the refusal details go to the server log.

Path two — trusted domains. Every provider (both OAuth and SAML) has a “Link to an existing account” setting:

Value Behavior
“Forbidden” (default, including for providers set up before the upgrade) a matching address doesn’t grant sign-in; the user is shown that they need to link the account themselves
“Trusted domains only” sign-in claims an existing account if the address’s domain is listed in the provider’s setting

The domain list is required with the second policy: an empty list isn’t saved. Domain comparison is exact, with no subdomains: corp.example doesn’t cover evil.corp.example.

Administrator accounts are never claimed — under either policy. An administrator links their own external sign-in themselves, from their profile settings.

After an upgrade, users who signed in via SSO and already have a link keep signing in as before: this rule concerns only the first linking. If sign-in relied on an address match before the upgrade, turn on trusted domains, or ask people to link their sign-in from profile settings.

Linking and unlinking external sign-in are recorded in the audit log as separate events (“Administration” → “Audit”) — both when the owner links from settings and when linking happens at first sign-in via a trusted domain.

The same rule applies to LDAP, where the key is the name rather than the address: signing in through the directory doesn’t claim a local account with the same name unless the “Link existing local accounts” setting allows it. Records already created through LDAP aren’t affected — they keep signing in as before.

OAuth2 providers

Connecting external OAuth2 and OpenID Connect providers for sign-in.

Users link external accounts: Settings / External sign-ins.

A provider’s name is normalized to GitRiver’s rules the same way SCIM and directory names are; one that isn’t usable as-is gets an oauth_ prefix, and a taken one gets a numeric suffix.

SAML 2.0

  • GET /api/v1/auth/saml/providers — list of providers
  • GET /api/v1/auth/saml/{id}/metadata — SP metadata (XML)
  • Identity provider setup: point it at the ACS URL from the metadata

Assertion signature requirements

What Accepted
Signature algorithm RSA or ECDSA with SHA-256, SHA-384, SHA-512 (including RSA-PSS). RSA-SHA1 is rejected — switch the provider to SHA-256
Reference digest (DigestMethod) SHA-256, SHA-384, SHA-512
Canonicalization exclusive (exc-c14n), inclusive (REC-xml-c14n-20010315), c14n11 — any variant, with or without comments
Transforms canonicalization and enveloped-signature only; XSLT and XPath are rejected
Reference URI empty (whole document) or #id; a reference to a file or an external address is rejected
DTD in the message not allowed (also forbidden by the SAML 2.0 standard)
Where the signature sits on the Assertion or on the root Response — both are accepted; there must be exactly one Assertion in the response
Certificate only the one set in the provider’s settings; a certificate from the response’s own KeyInfo is ignored. Both a full PEM and a bare body without wrapping are accepted; an invalid certificate is rejected immediately when the provider is saved
Conditions NotOnOrAfter and AudienceRestriction are required; Audience must match sp_entity_id — otherwise the assertion wasn’t issued for this instance
InResponseTo taken from SubjectConfirmationData inside the signed Assertion; the root Response’s attribute is only a fallback source
Logout request the signature must cover the LogoutRequest itself, not an element nested inside it

Assertions from Keycloak, ADFS, and Okta are supported.

Identifier format: persistent, not transient

Linking sign-in to an account is built on NameID, so the transient format doesn’t work as a linking key: the provider changes the value on every sign-in, and the link won’t be found next time.

  • When saving a provider, this format can’t be selected.
  • On sign-in, no link is created for it at all — even if the provider sent transient against the requested NameIDPolicy (SimpleSAMLphp does this with its defaults).
  • Whether to admit or refuse is decided by the address-linking policy. Allowed (trusted domains) — sign-in goes through by address match. Forbidden — sign-in is refused immediately, with an explanation.
  • Explicit linking (“Link sign-in” in profile settings) with a transient identifier is refused: the next sign-in wouldn’t find it.

emailAddress, persistent, and unspecified all work. If the provider doesn’t name a format at all, the assertion is accepted: by the standard, that is unspecified.

An invalid optional attribute doesn’t break sign-in

The address and display name from the assertion are accepted only if they pass the same rules as ordinary registration: the address by format, the name by length (255 characters max). An invalid value is skipped with a warning in the server log, and sign-in continues: you can work without a name, but not without sign-in. The same rule applies for sign-in via OAuth and LDAP.

If sign-in fails

The reason for the refusal is written to the server log as a warning with a check field — the name of the check that failed:

check What’s wrong
base64, utf8 the response doesn’t decode — check the binding on the identity provider side
status the provider didn’t return Success
issuer the response’s Issuer doesn’t match idp_entity_id in the settings
signature the signature didn’t verify, the certificate is wrong, or the algorithm isn’t in the table above
assertion-scope the signature doesn’t cover the Assertion, or there’s more than one in the response
conditions expired (NotOnOrAfter), missing AudienceRestriction, or Audience didn’t match sp_entity_id
in-response-to the response doesn’t correspond to any sent request (or it’s already been used)
replay this same assertion has already been presented
attributes there’s no NameID in the assertion
transient-name-id the provider returned a transient identifier, and address-based linking is forbidden for it — see “Identifier format” above
email the provider didn’t send an email, and NameID doesn’t look like one — there’s nothing to register a new user with
registration the user is new, and registration is closed on the instance (Administration → System)
auto-register the user is new, and auto-registration is off for the provider
email-link an account with that address already exists, but address-based linking is forbidden by the provider’s policy, or the address’s domain isn’t on the trusted list
link-owner this SAML sign-in is already linked to a different account
slo-unsigned, slo-signature, slo-scope, slo-name-id the same, for a logout request: unsigned, signature didn’t verify, signature doesn’t cover the request itself, no NameID

OpenID Connect (as a provider)

GitRiver can act as an OAuth2/OIDC provider for other services:

  • GET /.well-known/openid-configuration — configuration
  • GET /.well-known/jwks.json — public keys

id_token signing — RS256. The key is generated automatically on first startup: the private part is stored encrypted in the database (the encryption key is derived from jwt_secret), the public part is served in the JWKS. It needs no separate setting, and it’s included in a database backup along with everything else.

Two operational rules follow from this:

  • jwt_secret can’t be changed without losing the signing key. After the secret changes, the old signing key can’t be read, the instance generates a new one, and id_tokens issued before the change stop verifying. Recipients experience this as a one-time re-sign-in.
  • Restoring the database from a backup also restores the key, so recipients don’t need to re-fetch the JWKS after a restore.

What a recipient must do (in this order — as OIDC Core requires):

  1. Fetch the keys from jwks_uri and find the one whose kid matches the id_token’s header. The key set is exactly that — a set: the recipient must select by kid and re-fetch the set when it encounters an unfamiliar one, rather than memorizing a single key forever. Right now the set has one active key, and it only changes together with jwt_secret (see the operational rules above): at that point the old key leaves the set, and id_tokens it issued stop verifying — recipients experience this as a one-time re-sign-in. There’s no separate command to rotate the signing key while leaving the old one published.
  2. Verify the signature with this key, along with iss, aud, and exp.
  3. Compare nonce against the one sent in the authorization request. GitRiver returns it unchanged. If nonce wasn’t sent in the request, the claim is absent from the token — it never comes back empty.

An id_token issued on a token refresh (grant_type=refresh_token) contains no nonce: the user doesn’t take part in that exchange (OIDC Core 12.2).

A refresh token is single-use, and presenting it again revokes the grant. Every refresh issues a new pair; the previous refresh token doesn’t just stop working — its reuse means one pair is being used by two parties, and the whole grant is revoked (RFC 9700 §4.14.2). Hence the rule for a recipient: save the new pair before the old one is needed again, and never retry a refresh with the old token. If you lost the response to a refresh, start over with an authorization request (RFC 6749 §10.4) rather than retrying with the old token: the grant is already revoked, and the user will need to consent again.

How a recipient authenticates at /oauth/token — either of two ways, but exactly one at a time (RFC 6749 §2.3): the Authorization: Basic header (values are form-encoded before being joined) or the client_id/client_secret fields in the body. Presenting both at once is rejected, not resolved in either one’s favor.

Public applications (the “Confidential” toggle off — single-page apps, mobile and desktop clients) have no secret and don’t authenticate at /oauth/token at all: the discovery document declares this method as none. PKCE is mandatory for them:

  • an authorization request without code_challenge (method S256 only) is rejected — both at GET /oauth/authorize, before consent is shown, and at code issuance;
  • a code issued to a public application without PKCE isn’t accepted for exchange.

For a confidential application, PKCE remains a recommendation: there, ownership is confirmed by client_secret.

A secret is never issued to a public application — not on creation, nor via the “Regenerate secret” button (it isn’t even shown for such an app, and the request is rejected): /oauth/token doesn’t ask a public application for a secret.

An application’s nature is set at creation and doesn’t change: a confidential application can’t be turned into a public one or vice versa — you need to create a new one.

redirect_uri is required in the exchange request and is checked against the one the code was issued for (RFC 6749 §4.1.3) — it isn’t enough for the address to simply be registered with the application.

A /oauth/token refusal comes in the RFC 6749 §5.2 form — a code in the error field and a human-readable explanation in error_description:

error What happened Response code
invalid_request a parameter is missing (code, client_id, redirect_uri, code_verifier for a PKCE code) or was presented twice 400
invalid_client the client didn’t authenticate: wrong secret or a client_id naming someone else 401 + WWW-Authenticate
invalid_grant the code was spent, expired, issued for a different redirect_uri, not confirmed by PKCE, or issued to a public application without PKCE 400
unsupported_grant_type grant_type isn’t supported 400

Two-factor authentication (2FA)

For users

  1. Settings / Security — enable TOTP
  2. Scan the QR code with an app (Google Authenticator, Authy)
  3. Save the backup codes

API

Action API
Get QR code GET /api/v1/auth/totp/setup
Enable TOTP POST /api/v1/auth/totp/enable
Disable POST /api/v1/auth/totp/disable
New backup codes POST /api/v1/auth/totp/backup
Verify at sign-in POST /api/v1/auth/totp/verify

External sign-in

The second factor is asked for on every sign-in method: local password, LDAP directory, SAML, and OAuth. The provider only confirms who arrived; a session is issued after a one-time or backup code, and external sign-in leads to the same code-entry page, /login/totp, as ordinary sign-in.


Storage (S3)

Configuring S3-compatible storage for the container registry.

Action API
Get settings GET /api/v1/admin/storage
Save PUT /api/v1/admin/storage
Test connection POST /api/v1/admin/storage/test

One and the same store (S3 or the local filesystem) serves the container registry, Git LFS, and the Composer registry — switching storage is transparent to all three.

No temp directory needs configuring for S3. Files that can only be parsed whole (a .nupkg package, a Pages site ZIP) are received into the upload-staging directory beside git_repos_path, not into the system /tmp: plan space for them on the same disk as the repositories. The temp_dir field in the API response has no effect.


Legacy CI artifacts

Artifact archives of jobs run by early GitRiver versions have neither a retention period nor a size recorded: background cleanup never deletes such archives, and the max_ci_artifacts quota doesn’t see them. On an installation that ran on external runners for a long time, this can be the whole accumulated artifact directory.

Backfilling an expiry date means the accumulated archives get deleted on the next cleanup pass, so it isn’t done automatically on a version upgrade. It’s a separate administrator action, in the Administration → Storage section, always in two steps.

Action API
Report: how many jobs and bytes GET /api/v1/admin/ci-artifacts/legacy
Register the archives POST /api/v1/admin/ci-artifacts/legacy/recompute

The report changes nothing: the numbers shown are exactly what registering the archives will do. In the report:

  • jobs without an expiry and their volume — they will get an expiry date and a size recorded;
  • how many of them will get the default expiry (30d) — the commit’s workflow either didn’t set expire-in, or the workflow itself can no longer be read (the commit or file is gone);
  • permanent ones (expire-in: never) — only a size is recorded for them: the archive occupies quota space but isn’t subject to deletion, and no expiry is set for it;
  • jobs with no archive on disk — need no accounting.

The expiry is counted from when the job finished, not from the moment of processing: a years-old archive shouldn’t get another 30 days of life just because the recompute ran today. So for most legacy archives the expiry will already be in the past, and the next hourly cleanup pass will delete them — the report card itself warns about this. Running it again is safe: jobs already registered aren’t processed again.


CI archive integrity

An artifact or cache archive appears under its final name only once it’s complete: while it’s being assembled or received, it sits nearby under a temporary name (<name>.tar.gz.tmp-…) and gets its final name only once it’s fully written to disk. This holds both for archives received from an external runner and for archives packed by the built-in runner.

What this means for an administrator:

  • after a machine failure (power loss, kernel panic — not a service stop), an archive is either present and intact, or absent entirely. There’s no truncated .tar.gz that a dependent job fails to unpack, or that restores a corrupted cache: a missing archive is an expected outcome, and rerunning the job assembles it again;
  • concurrent jobs sharing a cache: key don’t interfere with each other. While one is saving its cache, another unpacks the previous archive whole, never one half-written; whichever job finishes saving last wins;
  • a failed pack doesn’t destroy what’s already accumulated: if tar couldn’t assemble the archive, the previous cache under that key stays as it was;
  • the cost is flushing the archive to disk before it gets its name. On a fast local disk that’s a small fraction of the time the archive spends traveling over the network; on slower or networked storage the share is more noticeable.

Temporary .tmp-… files clean themselves up on any outcome — both success and failure; a surviving temp file is left only by a process killed mid-write. Neither a job artifact nor a cache archive counts it (both categories only count a name ending in .tar.gz), and it doesn’t count toward the max_ci_artifacts or max_ci_cache quotas. In the artifacts directory, the remnant leaves along with the pipeline’s directory; in the cache directory, cache maintenance removes it — but only once the file is more than a day old: a fresh one might be an archive write happening right now, and interrupting it would corrupt a running build’s cache.


Git LFS

LFS objects are stored in shared storage (S3 or the filesystem), addressed by content (the object’s SHA-256). Upload goes through git lfs push, download through git lfs fetch/pull.

Storage is shared across the whole install, but ownership of an object isn’t: which repository owns it is tracked separately, and download, batch, and verify respond only with objects belonging to their own repository. Two consequences follow for an administrator:

  • the same file, encountered by a given repository for the first time, is re-uploaded by the client even if an identical one is already in storage: it won’t take disk space (storage is content-addressed), but it will spend bandwidth;
  • a fork gains rights to the parent’s objects at the moment it’s created; forks created before an upgrade get these rights automatically on the first run of the new version.

LFS usage is counted by these ownership links, so a fork increases the owner’s usage by the size of the inherited objects, even though it takes no disk space. That’s exactly why the LFS limit isn’t applied when creating a fork; the server still rejects uploads of new objects beyond the limit.

CI runner and LFS

The built-in runner fetches the working copy’s LFS objects through the server’s HTTP API at base_url, so that address must be reachable by the built-in runner. Access is granted by a short-lived token (15 minutes by default — lfs_token_ttl_secs): it’s written only to the job working copy’s .git/config and is removed together with it after the job.

LFS token lifetime

# gitriver.toml
lfs_token_ttl_secs = 900   # default; increase if large objects
                           # can't finish downloading within 15 minutes

or via the environment variable: GITRIVER_LFS_TOKEN_TTL_SECS=1800.

The value should be no less than the time git lfs fetch takes to download the largest binary object in the longest CI job. With a shorter lifetime, the download will fail with a 401 partway through.


Wiki

A repository’s wiki is stored as a separate bare repository on disk next to the repository itself: {git_repos_path}/{owner}/{repo}.wiki.git. It’s included in a backup along with the repository directories, and needs no separate setting.

The wiki has three permanent limitations:

  • The wiki isn’t a separate repository. Its directory is created when the FIRST page is created: a repository whose wiki has never been touched has no directory at all. The wiki doesn’t show up in repository lists, a pull request can’t be linked to it, and there’s no reviewing wiki edits.
  • There’s no git access to the wiki. Cloning .wiki.git or pushing to it over HTTP or SSH isn’t possible. The only entry point is the UI and the wiki API (/api/v1/repos/{owner}/{name}/wiki/...).
  • There’s a single branch — main, fixed by the platform itself. There’s no setting to change it, and the wiki never has a second branch.

Wiki permissions come from the repository: reading is available to anyone who can see the repository, editing to anyone with write access. There’s no separate access control for the wiki, and none is needed.


Kubernetes clusters

Connecting clusters for application deployment.

Action API
List clusters GET /api/v1/admin/k8s-clusters
Add POST /api/v1/admin/k8s-clusters
Test connection POST /api/v1/admin/k8s-clusters/{id}/test

Security

Restricting access by network address

Rules come in two kinds, and their verdicts are INDEPENDENT: access is granted only if both allow it.

Kind Who sets it API
Global — “who’s allowed onto the instance” instance administrator /api/v1/admin/ip_rules
Group-level — “who’s allowed into the group” group owner or instance administrator /api/v1/groups/{path}/ip_rules

Within one kind, a set with no matches is treated as follows: allow only — an allow list, everything not matched is closed; deny only — a deny list, everything not matched is open.

A group rule applies to ALL entry points, not just the web UI: /api/v1, the /api/v3 and /api/v4 compatibility layers (for /api/v4, both address forms: /api/v4/projects/{path} and /api/v4/projects/{number}), git over HTTP and SSH, LFS, and the image registry. No token type gets its own exception.

Check your runners’ network. CI clones the repository over an ordinary git path and presents the job token. If a group is closed with an allow list, the runners’ addresses must be on it — otherwise jobs will start failing at the clone step. There’s no exception for service tokens.

A parent group’s rule also applies to subgroups. Group membership inherits down the tree, and the address restriction goes with it. A subgroup’s own allow doesn’t override the parent’s restriction: permission is required at every level.

Listings. Repositories of a closed group, the group itself, its issues and pull requests in summaries, notifications, and feed events all disappear from lists and from code search scope. A results page may end up shorter than requested — pagination doesn’t break and doesn’t lose objects.

What the restriction does NOT hide (deliberately): name availability (GET /api/v1/namespaces/{name} — the namespace map is shared across the instance), personal counters on the home page, and the activity heatmap. These are aggregate numbers and a name map, not group content.

Actions on a group are closed the same way as reading them: from a forbidden address you can’t create a repository in it, create a subgroup, or transfer a repository into or out of it.

A published Pages site for a closed group isn’t served to a forbidden address, even if the repository is public: the site is the same repository content.

A network refusal is distinguishable from “not found.” Someone who can already see the repository gets 403 with the reason “access from this network address is forbidden by policy.” Someone who can’t see it gets 404, same as for a nonexistent address.

Protection against self-lockout. Viewing and removing rules, as well as GET /api/v1/me/ip (your own address, as the server sees it), aren’t themselves subject to the restriction and are available in every edition. Creating and editing instance-level rules is an install-level capability (Max edition), group-level rules require Pro and a seat; but once the license expires, the filter keeps working, and a blocking rule can always be removed — with no database edits needed. The Community edition UI shows the list and a delete button, and disables “Add.”

Behind a reverse proxy, the address is taken from X-Real-IP/X-Forwarded-For only if the connection came from a trusted address (trusted_proxies in the configuration; loopback and private networks by default). These headers aren’t accepted from other addresses. If the address can’t be determined, the request is rejected wherever rules are set up.

The same rule applies everywhere an address is shown to a person: the sign-in list in the profile (“Settings” → “Sessions”), records of successful and failed sign-ins, and the session issued by the initial setup wizard. An address from a header that didn’t come through a trusted proxy doesn’t make it into these; an address that couldn’t be determined stays blank.

Code scanning

  • Security findings — static analysis (SAST) results
  • SARIF import — uploading results from external scanners
  • License compliance — checking dependency licenses

Repository security settings

Action API
Parameters GET /api/v1/repos/{owner}/{name}/security/settings
Update PATCH /api/v1/repos/{owner}/{name}/security/settings
Run a scan POST /api/v1/repos/{owner}/{name}/security/scan

Password requirements

Requirement Value
Minimum length 12 characters (characters, specifically: a Cyrillic password counts the same as a Latin one)
Maximum length 1024 characters — a long passphrase goes through freely
Check against a leaked-password list the password is rejected if it appears in the list of most commonly leaked passwords (ASVS 2.1.7)

The leaked-password list is built into the distribution and needs no internet access: it’s the top hundred thousand passwords from the NCSC list, sourced from Have I Been Pwned, keeping only entries no shorter than the minimum length — the rest wouldn’t pass the length check anyway. Casing is ignored in the comparison: Password12345 and password12345 are the same guess by an attacker.

The check applies wherever a password is SET: registration, a user changing it, an administrator resetting it, the initial setup wizard. Already-created passwords aren’t forced to change: after an upgrade, everyone signs in as before.

The list is updated with releases and isn’t edited by hand. The terms under which the list is distributed are included with the release (THIRD-PARTY-NOTICES.md, “Bundled data” section).

Rate limits

A limit applies to each server replica separately: behind a load balancer, traffic is split across replicas, and the install’s total throughput scales with their number — the table below gives the limit for ONE replica, not the whole install. Exceeding it returns 429 Too Many Requests with the body {"error", "code": "rate_limited", "retry_after"} and the headers Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining.

Group Routes Key Limit, requests/minute
auth sign-in, registration, second-factor check address 10
sso sign-in via an external provider: /api/v1/auth/saml/** and /api/v1/auth/oauth/**, any method address 300
oidc code and token issuance to a third-party app: /oauth/authorize, /oauth/token, /oauth/userinfo address 600
scim submitting accounts: /scim/v2/** provider token 600
api_write POST/PUT/PATCH/DELETE on the native interface (/api/v1/) and the compatibility layers (/api/v3, /api/v4) address 60 anonymous, 300 with a token
search code search address 20 anonymous, 60 with a token
git_write git-receive-pack (push over HTTP) address 30 anonymous, 120 with a token
runner external runner protocol: /api/v1/runner/**, including reads runner token 600
ai calls to the language model: analyzing a failed job, analyzing a diff, checking the connection to the provider token, otherwise address 10
knowledge_view marking a knowledge entry as shown or read: .../knowledge/shown, .../knowledge/entries/{id}/read token, otherwise address 600

API reads (GET, other than search, SSO, OIDC, SCIM, and the runner protocol) aren’t limited.

The group is determined by the route, not by the request address. A write through a compatibility layer spends the same limit as the same write through /api/v1 — switching dialects doesn’t get around it. Requests to nonexistent addresses don’t count toward any limit.

Marking a knowledge entry as shown is counted separately from other writes, and by token rather than address: agents mark entries as shown often, and in the shared write limit they would take it away from people working from the same address. A mark dropped by the limit isn’t deferred, it’s lost, so its threshold is the same as for runners.

Sign-in via an external provider is counted for any request method, including GET: some SAML steps use GET, and slo/callback accepts both POST and GET — one limit covers both. The provider ID is public (GET /api/v1/auth/saml/providers), and no authentication is needed for this step.

The sso limit is more generous than the anonymous write limit because the key is the connection address: behind a reverse proxy, a whole organization’s sign-ins arrive from one address and share one limit. 300 a minute is five sign-ins per second per replica. The threshold is the same with Authorization present: at sign-in there’s no token yet.

Scopes outside /api/v1/ are counted in their own groups: a surge on /oauth/token doesn’t knock out employee sign-in through the provider.

Provider metadata endpoints (/.well-known/openid-configuration, /.well-known/jwks.json) have no limit: recipients hit them most often when a key changes, and refusing them would break their signature verification. How long a recipient may keep the response is declared in Cache-Control.

The /oauth/callback UI page isn’t part of the oidc group and isn’t rate-limited.

SCIM is counted by the provider’s token, not by address. An organization’s initial export is hundreds of requests in a row: accounts and groups are created one at a time. By address, such a batch would share a limit with people behind the same reverse proxy and would break off midway, and on a refusal the provider stops the whole sync and requires administrator intervention.

Not covered by any limit: serving a repository over HTTP (git-upload-pack), LFS, the image registry (/v2/**), and Pages. Here a single user step is a request per OBJECT (a git push with large files makes one LFS call per file, docker push uploads a layer in chunks), and all of an organization’s runners often arrive from one address. If you need a limit for these, set it at the reverse proxy, where the client’s real address is visible.

A higher limit (the second threshold column) is granted by an Authorization: Bearer header with a JWT-like token. The token’s signature isn’t checked at this step, so the header only raises the threshold while the key stays the address: supplying an arbitrary string doesn’t get a client its own limit. auth, sso, oidc, scim, runner, and ai have one threshold.

The address is taken from the connection, X-Forwarded-For isn’t read. Behind a reverse proxy, all clients arrive from one address and share the group’s limit. For the UI and API this is expected (the proxy usually enforces its own limit too); runners and SCIM are counted by token and aren’t affected.

The runner protocol is counted by token. The key is a fingerprint of the presented token, not the address: runners behind the same NAT or a shared proxy don’t share a limit, and adding a runner doesn’t knock out ones already working. The limit of 600 requests a minute per runner is insurance against a looping runner, not a working norm: a runner polling every 2 seconds uses up less than a hundred. The norm is set by --poll-interval, see Running an external runner.

A runner survives a rate refusal on its own. On getting 429 on any protocol call, it waits the time named in Retry-After (no more than 60 seconds) and retries — up to three times. A limit of its own may also sit at a reverse proxy in front of the server. Once retries are exhausted, the runner names the reason in the job log along with the 429 code — for example in the line about an unsaved artifact archive — so the refusal doesn’t go unnoticed.

Password brute-forcing is limited by a separate count. Besides the auth group’s limit, every failed sign-in attempt is tallied by the pair “username — address”: five failures in fifteen minutes stop further attempts for that pair before the password is even checked. The count is shared across web sign-in, Basic-auth password for git over HTTP, LFS and the image registry, and sign-in in the initial setup wizard; a successful sign-in clears the pair’s count. The count is kept per pair, not per name, so a different address can’t lock the name’s owner out. Only a genuine credential refusal counts as a failure: a database outage doesn’t lock sign-in.

An exhausted count is a RATE refusal, and web sign-in (POST /api/v1/auth/login) responds to it the same way the rate limiter does: 429 with the code rate_limited, a Retry-After header, and a retry_after field in the body. The UI substitutes this time into a message in the user’s language (“Too many requests. Try again in 4m 12s”), while a script gets a number without parsing text. The other paths sharing this count — git over HTTP, LFS, the image registry — respond in their own protocols: they’re read by a third-party client, not a browser.

Exempting addresses from limits. The rate_limit_exempt_cidrs setting (the GITRIVER_RATE_LIMIT_EXEMPT_CIDRS variable, subnets comma-separated) lists addresses to which rate limits don’t apply: rate_limit_exempt_cidrs = ["10.20.0.0/16"]. The list is empty by default — everyone is limited.

Needed where a whole stream of traffic arrives from one address and the normal thresholds would get in the way: an E2E run, load testing, an internal integration. An exemption lifts all rate limits for the named addresses, including the sign-in limit, so keep the list narrow: the test environment’s subnet, not “the whole corporate network.”

What it does NOT lift: the failed-password-attempt count (five per “username — address” pair in fifteen minutes, see above) — it’s kept separately, and password guessing from an exempted address is stopped the same way as from any other.

The TCP connection’s address is checked, not the X-Forwarded-For header: behind a reverse proxy, the list must hold the proxy’s own address.

An entry of 0.0.0.0/0 (and ::/0) exempts everyone and amounts to running with no rate limits at all. Don’t do this on a production install.


Licensing

Full documentation: licensing.md

There’s one reason this happens: the entitlement check period has run out — a signed activation response is valid for a limited time (14 to 90 days, depending on the subscription term) and is renewed by any new signature from the publisher. The instance hasn’t received one in too long.

Signs: a red banner reading “entitlement check overdue” on the Administration → License page, an icon instead of a “valid through …” date on the license record, a license_lease_expired event in the activity feed, and an email to administrators about the same thing. Ahead of time, while features still work, an approaching deadline is flagged by a license_lease_expiring event and email: fix the connection before the sections close. Paid calls respond with 403 and the code license_required; Git, CI, issues, and all data keep working.

Nothing needs to be paid. Steps:

  1. Open outbound access from the instance to the license server — it asks for confirmation on its own, once a day, and once the entitlement check period is close to or past its end, every 1–6 hours; the Check now button doesn’t wait for either.
  2. If there won’t be connectivity (a closed network) — get a signed activation response the same way the license was activated in the first place: from the license owner’s account, or with the license key (see “Who Can Obtain a Signed Response”) — and paste it on the same page. For a closed network, this path is the primary one, not a fallback.

Seats aren’t affected by any of this: the entitlement check doesn’t touch them, and once it succeeds, everything reopens on its own, with no reactivation needed.

Database fingerprint: restoring from a backup and upgrading the DBMS

The license is bound to the installation’s database (agreement clause 4.13). Restoring the database from a backup into a new cluster and a major PostgreSQL upgrade (pg_upgrade) change this binding. Signs: the instance refuses to apply an activation response, saying “this response was issued for a different database,” and the license page shows a “license bound to a different database” banner with the fingerprint of this installation’s database — give it when you get in touch. The banner appears BEFORE the entitlement runs out.

The practical rule that follows: request a reissue ahead of time, as part of planning the DBMS upgrade, not after it. There’s the whole rest of the entitlement check period to do this — the previous response keeps working until it expires, and a database upgrade on its own doesn’t turn off paid features.

Steps: on the license page, click “Add license” and paste the same license key again — the instance will prepare a new activation request — then obtain a response for it the same way the license was activated (see “Who Can Obtain a Signed Response”). If the key isn’t at hand, tell the publisher the fingerprint from the license page and ask for a reissue. Reissuing is free of charge (agreement clause 4.13), so you don’t have to move the database in an emergency.

The reissued response is pasted in by hand, on the same page: the automatic entitlement check doesn’t rebind the license to a different database. Details — “Moving the Database and Restoring From a Copy”.

Database clock and the entitlement check period

Keep the clocks on the installation and database machines synchronized (NTP). If the database cluster’s clock has jumped far forward — a misbehaving hardware timer, a machine snapshot with a future date, a restore onto a machine with the wrong date — license and entitlement check deadlines may end earlier than the calendar says, and setting the clock right won’t bring them back. Reissuing the activation response helps — the same way as after moving the database.


Audit log

Administration → Audit, GET /api/v1/admin/audit-log. Filters: by user (actor), event type (op_type), repository (repo_id), and time (before); pages of up to 200 records.

The log holds three kinds of events: actions on repositories and issues (also visible in the public feed), sign-in events, and administrative changes — the reason the log gets read in the first place.

What changed op_type What’s in the content
Instance settings: LDAP, mail, email templates, the assistant, branding, storage, registry cleanup, Kubernetes runner clusters and configurations, the SSH server, registration, quota defaults, backups (creation, deletion, restore, schedule), license policies, repository security settings, and secret-scanning templates update_instance_settings section, action
A sign-in provider created, changed, removed (OAuth, SAML) create_auth_provider, update_auth_provider, delete_auth_provider kind, name and ID
A credential issued, changed, or revoked: personal access token, deployment token, external-management token, SSH key, GPG key, backup download link, OAuth application and its secret, a grant given to an application, sign-in session issue_credential, update_credential, revoke_credential kind, name or fingerprint
An account’s sign-in closed and restored block_user, unblock_user target
Direct repository access granted or revoked add_collaborator, remove_collaborator name and role
Repository visibility changed change_repo_visibility from, to
Repository transferred transfer_repo from, to, to_kind
A group created or removed; membership and roles changed create_group, delete_group, add_group_member, remove_group_member, change_group_member_role group path, user, role
A webhook created, changed, removed create_webhook, update_webhook, delete_webhook scope, delivery address
A custom role created, changed, removed, assigned, unassigned create_custom_role, update_custom_role, delete_custom_role, assign_custom_role, unassign_custom_role group, role, user
A network access rule created, changed, removed create_ip_rule, update_ip_rule, delete_ip_rule scope, cidr, action
An owner’s quota changed update_quota owner, ID, limit (storage, ai_tokens, knowledge_drafts), action
Password changed — by the person themselves or by an administrator password_reset target, source
A CI variable created, changed, removed update_ci_variable, delete_ci_variable scope, variable name
A deployment environment changed or removed update_environment, delete_environment environment name, approval flag

Deleting a license policy removes it from every repository it was attached to — each one gets its own action: unbind entry with the reason policy_deleted and the repository itself, so the merge-block removal shows up in each repository’s log. Deleting it again returns 404 and leaves no entry.

Secret values are never written to the log. What’s recorded is the fact: a token was issued, a variable was changed, a password was reset — but not the token itself, the variable’s value, or the password. A webhook’s address IS recorded: it answers the question of where data started going, and it isn’t a secret.

Administrative entries are marked private: they don’t appear in public repository and profile feeds; only an administrator sees them in the log, and the person themselves in their own feed.


Monitoring

Liveness check

GET /health

Responds 200 with status and version, and 503 when the database is unreachable. The endpoint lives at the root, not under /api/v1.

The response comes back fast either way: the database check takes no more than two seconds. So a probe only needs timeoutSeconds: 3 — it will get an actual 503, not a timeout of its own, and the log will show that the server did respond. The shipped chart sets timeoutSeconds: 5, with the same margin.

Prometheus metrics

GET /metrics

Metrics in Prometheus format: HTTP requests, pipeline statistics, and more.

CI resource slice. systemd sets the slice’s limit, not the server’s settings. The admin panel (“System” → “CI resource slice”) shows the same thing briefly: name, limits, memory used, and trigger counters. The metrics are for anyone who wants to see this over time:

Metric What it shows
gitriver_ci_slice_cpu_quota_cores the slice’s CPU quota, in cores (CPUQuota=)
gitriver_ci_slice_memory_max_bytes the slice’s memory limit (MemoryMax=)
gitriver_ci_slice_memory_current_bytes how much memory the slice is using right now
gitriver_ci_slice_cpu_throttled_seconds_total how long the slice’s jobs have spent stalled against the CPU quota
gitriver_ci_slice_oom_kills_total how many processes the kernel killed for hitting the slice’s memory limit

The last two are counters: watch their rate (rate(gitriver_ci_slice_cpu_throttled_seconds_total[5m])). If the first grows, builds are running slower than they could; if the second grows, jobs are short on memory. What to do about it — installation guide.

These metrics don’t exist at all if the slice is disabled (ci_cgroup_parent = "") or its cgroup isn’t readable by the server — for example, a server running in a container with its own cgroup namespace. No zeros are published in that case: a zero would read as “the limit was removed.”

Access. By default the endpoint is open, no sign-in required. There are no repository or user names in the metrics (the path label is the route template, not the actual path), but install-wide aggregate counters — how many users, repositories, security findings — are visible to anyone who reaches the address. On an install reachable from the internet, set a token:

metrics_token = "long-random-string"

or the GITRIVER_METRICS_TOKEN environment variable. The collector must then send the Authorization: Bearer <token> header — in Prometheus that’s authorization.credentials in the scrape target configuration.

DORA metrics

Metric API
Deployment Frequency, Lead Time, Change Failure Rate, MTTR GET /api/v1/repos/{owner}/{name}/dora/metrics
Value Stream Analytics GET /api/v1/repos/{owner}/{name}/dora/vsa

New versions

Once a day the install asks the publisher whether a newer version has come out, and announces it itself — on the “System” page next to the version number, and by email to administrators. The email goes out ONCE PER VERSION: the mark that a given version has already been announced survives a restart.

Nothing is downloaded or installed. The publisher’s response isn’t signed, and it can’t be trusted as a command: the result of the check is just a string with a link, and the decision, along with verifying the package’s signature, stays with the administrator.

Action API
Check now GET /api/v1/admin/update-check
Result of the last check GET /api/v1/admin/system, the update field

The check’s response distinguishes three outcomes: a new version is out, the latest is already installed, and the check failed (checked: false with a reason). The third case is never presented as the second: “no updates” and “we couldn’t ask” are different answers.

Settings (gitriver.toml, details in the installation guide):

Setting Default Why change it
update_check_url https://gitriver.com/releases/latest a mirror in a closed network, a proxy address
update_check_enabled true for a free install this is the only outbound call; false turns it off entirely

An install with a license also makes an activity check-in call (see licensing.md); a free one makes only this check.


Branding

Appearance customization: logo, title, colors.

Action API
Get GET /api/v1/admin/branding
Update PUT /api/v1/admin/branding
Public GET /api/v1/branding

CI/CD runners

Built-in runner

By default, GitRiver runs CI jobs on the host machine via Docker.

External runners

Action API
List runners GET /api/v1/admin/runners
Register POST /api/v1/admin/runners
Update PUT /api/v1/admin/runners/{id}
Pause / resume POST /api/v1/admin/runners/{id}/pause (`{“paused”: true
Reissue token POST /api/v1/admin/runners/{id}/token
Delete DELETE /api/v1/admin/runners/{id}

Routing jobs by labels:

# In the workflow
jobs:
  build:
    runs-on: [self-hosted, linux, gpu]

Runner scope

A runner sees only jobs within its scope:

Scope What it picks up Who registers it
Whole server (instance) jobs from every repository server administrator
Group (group) jobs from that group’s repositories, and — with “covers subgroups” enabled — its whole subtree group maintainer or owner
Repository (repo) jobs from one repository repository administrator (ci:admin permission)

A group scope’s inheritance runs downward only, like membership and build variables: a group runner with the flag enabled picks up jobs from its whole subtree, while a subgroup’s runner never gets its parent group’s jobs — neither with the flag nor without it.

The machine’s owner enables subgroups — with the “Covers subgroups” toggle (the covers_subgroups field, on by default for new group runners). When enabling it, keep in mind: subgroup jobs run scripts on the machine written by people who may have no rights at all in the ancestor group itself. The decision belongs to whoever is responsible for the machine.

Upgrading from versions before 1.1.0 doesn’t change behavior. For group runners registered earlier, the toggle is off after the upgrade: they keep picking up only their own group’s jobs. Turn it on wherever you want a runner to cover the subtree — on the group’s “Runners” page, or with PUT /api/v1/admin/runners/{id} and {"covers_subgroups": true}.

The administrator sets scope explicitly, via the scope field in the request body ({"type": "repo", "id": "<uuid>"}; without it — instance). The covers_subgroups field is accepted only together with a “group” scope: for other scopes the request is rejected.

Repository and group owners can register runners in their own scope; there, the server fills in the scope from the request’s address, the body doesn’t set it:

Action API
Repository runners GET/POST /api/v1/repos/{owner}/{name}/runners
Pause a repository runner POST /api/v1/repos/{owner}/{name}/runners/{id}/pause
Reissue token POST /api/v1/repos/{owner}/{name}/runners/{id}/token
Delete a repository runner DELETE /api/v1/repos/{owner}/{name}/runners/{id}
Group runners GET/POST /api/v1/groups/{path}/runners
Pause a group runner POST /api/v1/groups/{path}/runners/{id}/pause
Reissue token POST /api/v1/groups/{path}/runners/{id}/token
Delete a group runner DELETE /api/v1/groups/{path}/runners/{id}

The list shows runners that serve this scope: its own and inherited ones from above — ancestor groups’ runners and instance-wide runners. Inherited ones are flagged inherited and listed after the scope’s own; managing them stays where they’re registered — pausing, reissuing a token, or deleting from someone else’s scope returns 404. They’re shown because they pick up this scope’s jobs and get its secrets: the list fully answers “who runs my builds.” A token is returned only once — at registration.

A runner lives exactly as long as its scope: deleting a repository or a group (including when a group is removed along with its parent) deletes their runners, and the tokens issued to them stop working. There’s no need to revoke them separately. This doesn’t apply to instance-level (instance) runners.

In the UI: repository “Settings” → “Runners,” a group’s page → “Runners.”

Pausing and reissuing a token

Pausing takes jobs away from a runner without deleting it. A paused runner:

  • gets no new jobs (the queue doesn’t select it);
  • finishes a job started before the pause and sends its result — pausing doesn’t interrupt a running build;
  • keeps its name, labels, and scope, and keeps checking in;
  • returns to work with the same call using {"paused": false} and picks up jobs starting with its very next poll (a few seconds).

Pausing is how you respond to suspicion: the runner is taken out of the queue while you figure out whose it is and what ran on it.

Reissuing a token is needed if a token leaked or the machine changed hands. The old token stops working right away, the new one is shown once, as at registration; the runner is then started with the new token. The runner itself, its name, labels, and scope stay in place.

A runner whose token was revoked (reissued, or the runner deleted) notices this on its very next poll: it logs that the server no longer recognizes the token, and shuts down — the process doesn’t keep spinning with an error every few seconds. Under autoscaling, it exits on its own, the same as after an ordinary job.

Registration, deletion, pausing, resuming, and token reissuance are recorded in the audit log (“Administration” → “Audit”) as separate events.

A runner is a trusted party. It receives job variables in plain text (including secrets available to its pipeline), a job token, and clone access; it’s also the one reporting the result back to the server — the server takes it at its word. So an instance-level (instance) runner should only be placed on hardware you control, and teams should be given repository- or group-scoped runners instead: their scope limits both the jobs and the secrets available to them.

Kubernetes autoscaling

Action API
List configurations GET /api/v1/admin/k8s-runners
Create POST /api/v1/admin/k8s-runners
Update / delete PUT/DELETE /api/v1/admin/k8s-runners/{id}

Every ten seconds, the controller checks the queue and, for each pending job matching match_label, creates a Job with a single gitriver-runner pod (up to max_pods at a time).

The pod image is set by the configuration’s runner_image field. The runner image ships together with each release — the same registry repository as the server image, under its own tag (<server image>:<version>-runner); give its full address in whatever registry your cluster pulls images from. This field is required at creation: a short name like gitriver-runner:latest would have the cluster look on Docker Hub and, finding nothing, leave the pod in ImagePullBackOff.

Such a runner lives for one job. Its scope is set from the repository of the job it was spun up for: the pod’s token opens only that repository’s jobs, not the whole instance’s. Once the job is done and the runner has been idle for fifteen minutes, its record is deleted along with its token; cleanup never touches a busy runner, no matter how long its build runs.

The token goes into a Secret, and the pod reads it via secretKeyRef. The Secret is subordinate to its Job: the cluster removes them together when the Job is cleared by ttl_after_finished. The account GitRiver uses to talk to the cluster needs create, patch, delete, and list rights on secrets in the runners’ namespace — the same rights already needed for jobs:

rules:
  - apiGroups: ["batch"]
    resources: ["jobs"]
    verbs: ["create", "get", "list", "delete"]
  - apiGroups: [""]
    resources: ["secrets"]
    verbs: ["create", "patch", "delete", "list"]

Without those rights, autoscaling doesn’t stop: the token goes straight into the pod spec instead, and a warning naming the namespace is written to the server log. The pod spec is readable by anyone with pod access in that namespace, so it’s better to grant the rights.

Running an external runner

The runner installs from its own package (gitriver-runner_<version>_<arch>.deb / .rpm) or runs from an image — the procedure is described in the installation guide. The package sets up the gitriver-runner service, which reads the address and token from /etc/gitriver-runner/runner.env; below are the same parameters as the runner itself accepts.

gitriver-runner run --url https://git.example.com --token grr_...
Parameter Environment variable Default Purpose
--workdir GITRIVER_RUNNER_WORKDIR /var/lib/gitriver-runner jobs’ working directory
--poll-interval GITRIVER_RUNNER_POLL_INTERVAL 5 interval for polling the server, seconds
--max-artifact-bytes GITRIVER_RUNNER_MAX_ARTIFACT_BYTES 4294967296 limit on a single job’s artifact size: both the received archive and its unpacked content — both on disk, not in memory
--max-artifact-entries GITRIVER_RUNNER_MAX_ARTIFACT_ENTRIES 200000 limit on the number of entries in an artifact archive
--git-timeout GITRIVER_RUNNER_GIT_TIMEOUT 120 limit on a single git operation while preparing the working copy (clone, fetch, checkout), seconds
--lfs-timeout GITRIVER_RUNNER_LFS_TIMEOUT 600 limit on fetching the working copy’s LFS objects (git lfs pull), seconds
--eraser-image GITRIVER_RUNNER_ERASER_IMAGE busybox the image used to clean a working copy left behind with root-owned files (see below)
--cgroup-parent GITRIVER_RUNNER_CGROUP_PARENT gitriver.slice the CI resource slice: the cgroup ALL of the runner’s containers go into. An empty string means no slice. The limit on the slice itself is set by systemd (see below)

CI resource slice

All of the runner’s containers — the job container, after_script, services: containers, and helper containers for cleaning up the working copy — go into one cgroup (--cgroup-parent), and the service itself lives in it too (Slice=gitriver.slice in the unit file). The name and default match the server’s: on a machine running both, CI usage is counted together, not as two independent halves.

The slice’s limit is set by systemd, not by runner settings:

systemctl set-property gitriver.slice CPUQuota=600% MemoryMax=12G

The slice’s unit file ships with the server package; on a machine running only the runner, systemd creates the slice itself on the first reference to it, and the command above works the same way. Without a limit, the slice remains a place to measure usage: systemd-cgtop gitriver.slice.

What the slice does NOT count — image builds: the Docker daemon creates their containers outside the slice. Details and what to do about it — CI guide.

Connecting to the server

The server address is set with --url. The runner works over http — test environments and closed networks without TLS rely on this — but it warns at startup: this connection carries its own token, job variables including secrets, and clone credentials, and over http they travel in plain text. For a server on the runner’s own machine (localhost, loopback address) there’s no warning: the traffic never leaves it.

Runner working directory

The working directory belongs to the runner entirely: it holds jobs’ working copies with their credentials (.git-credentials with the active CI_JOB_TOKEN, .docker/config.json with registry credentials) and archive-packing directories. So at startup the runner checks who owns it:

  • the directory doesn’t exist — the runner creates it itself with 0700 permissions;
  • the directory belongs to another host user (or there’s a symlink or a file at that path) — the runner doesn’t start and names the reason: it can’t work in a directory whose contents someone else controls;
  • the directory is its own but open to others (e.g. 0755) — the runner tightens it to 0700 and writes a line to the log about it.

The default directory sits outside the shared /tmp for exactly this reason: /tmp/name can be created in advance by any user on the machine, while /var/lib belongs to root. If the runner doesn’t run as root, an administrator needs to create the directory for it:

sudo install -d -m 700 -o gitriver-runner -g gitriver-runner /var/lib/gitriver-runner

Your own path is set with --workdir; the same requirements apply, and it shouldn’t hold anything else’s (including another service’s directory). Runner in a container: a directory mounted from the host must belong to the user the runner runs as INSIDE the container (usually root), or startup will refuse — and this isn’t a formality: into a directory owned by a different host user, that user can plant symlinks the runner would then follow with its own permissions.

Upgrading from earlier versions. The previous default was /tmp/gitriver-runner. The runner switches to the new directory and cleans up after itself at the old one: on its very first startup it does the same cleanup pass there, removing its own abandoned working copies (along with the job credentials inside them) and archives, and deletes the emptied directory entirely. This only happens if the directory belongs to the runner’s user: /tmp is shared, and the directory at that path might not have been created by it. If something is left over — a still-running runner of an older version is using it, something else is there, or permissions don’t allow it — the runner writes a line with the path and a fix (rm -rf /tmp/gitriver-runner) at every startup, for as long as the leftover sits on disk. To stay on the old path, set it explicitly — --workdir /tmp/gitriver-runner; then the directory must belong to the runner’s user, or it won’t start.

The runner first packs a job’s artifacts and cache as a file in the packing directory, then streams it to the server straight from disk: the runner’s memory usage doesn’t depend on archive size, but the working directory needs room for the archive. The packing directory lives until the upload finishes and is removed right after — including when the server didn’t accept the archive.

The reverse path works the same way: the runner receives a dependency (needs:) archive and a cache archive into the same directory as a file and unpacks it from disk, so the runner’s machine needs enough RAM for the job itself, not for the size of the archive. Plan working-directory space generously: while unpacking, it holds both the received archive and its unpacked content — both capped by --max-artifact-bytes. The received archive is removed with its directory right after unpacking, and one abandoned by a crashed runner is swept up on the next startup.

The runner fetches job code over HTTP, and the credentials for it are issued by the server along with the job — separate from CI_JOB_TOKEN and read-only for that job’s repository (see CI: cloning a repository with an external runner). No access needs configuring on the runner’s machine, and a stuck server or network cuts off the job at --git-timeout rather than blocking the runner.

The runner prepares the working copy the same way the built-in runner does: the job script gets git credentials from CI_JOB_TOKEN (git push works without manually assembling the URL), and LFS files arrive as content, not as pointers (see CI: git and LFS in an external runner’s working copy). LFS on the runner’s machine needs git-lfs installed: without it the job continues, but a warning goes to the log, and LFS files stay as pointers.

The runner unpacks previous jobs’ artifacts itself, rather than with system tar: an entry with a path pointing outward, a link outside the working directory, and special files are all skipped (the number skipped goes into the job log), and exceeding the limits above aborts the unpack. Raise the limits if the project’s builds routinely produce larger artifacts. A job’s cache (cache:) is restored by the same code, under the same limits.

The runner removes a job’s working copy itself once the job is done. It collects the temporary upload archives (artifacts and cache) next to it, in its own working directory, and removes them along with the packing directory. If the runner was killed mid-job (SIGKILL, a service restart, a host crash), both an abandoned archive and an abandoned working copy — along with the job’s credentials inside it — are removed on the next startup: no need to keep cleanup in cron. Several runner processes can share one --workdir: one process’s startup cleanup doesn’t touch the packing or working copy of a job another process is handling at that moment. The working copy and packing directory are held by lock files (.job-{id}.lock and lock inside .pack-*), so don’t remove anything in --workdir with outside tools while the runner is running.

Leftovers abandoned by a runner of an earlier version have no lock, so they’re removed at startup by AGE: temporary upload archives (.artifacts-upload-*, .cache-upload-*) after an hour, job working copies after a day, comfortably longer than the longest possible job (the timeout: ceiling is 6 hours). A working copy has to look like a working copy for this: cleanup won’t touch a directory without a clone and job service files, no matter how long it’s sat in --workdir. The flip side of the same rule: --workdir belongs to the runner entirely, and you shouldn’t keep your own things there either — including your own repository clones. No manual rm -rf is needed, and neither is stopping the runner: a job another runner process is handling right now (including an older version, during an upgrade window) doesn’t fall under these deadlines.

A job script with image: runs in a container as root, and files it creates in the working copy belong to root. With images that set umask 0077 (e.g. redis:7) this locks directories too: the runner’s user has no way around them. Normally the runner fixes ownership right after the script, and when cleaning up an abandoned copy it repeats the same fix in a container of THAT SAME image — it remembers the job’s image in its lock (.job-{id}.lock) when it claims the working copy.

If the lock has no image on record (a leftover from an OLDER runner version; a job with no image: that raised its own container) or the container daemon refuses to change ownership (userns-remap, the job’s image already removed from the local cache), an “eraser” image steps in: it deletes root-owned files rather than handing access back, and so it can handle any leftover — including one left by docker buildx under a foreign uid. The built-in server-side runner applies the same procedure with the same image (ci_eraser_image, see the installation guide). So an abandoned job copy leaves disk on the runner’s next startup on its own.

The default image is busybox (about 4 MB). It might not be on the machine: then the runner pulls it the first time it’s needed, i.e. on the first cleanup of a locked directory. In a closed network, there’s no network access for this — point it at your own image from a reachable registry (anything with sh, find, and rm will do) or pull busybox ahead of time:

gitriver-runner run --url ... --token ... --eraser-image registry.example.com/base/busybox:1.36

There’s one remaining case where the runner can’t clean up a leftover: no eraser image on the machine and nowhere to pull it from. Then at every startup the runner writes one line about the leftover, with the path and a fix:

<leftover kind>: <reason> — <remove manually>: sudo rm -rf /var/lib/gitriver-runner/<job_id>

The line is written as a warning to the runner’s own log, with a path field holding the leftover’s path. Its text is currently in Russian regardless of the language setting; look for the path field and the sudo rm -rf fix at the end.

Only someone with rights over root’s files can remove such a directory — either that, or fix the cause (pull the image, point --eraser-image at a reachable one) and restart the runner: it cleans up at startup. There’s no need to remove the lock next to the directory separately: once the directory is gone, cleanup removes the lock too on the next run. The line repeats for as long as the leftover sits on disk — and this is the only case where --workdir needs manual intervention.

Runner protocol

Runners talk over a REST API:

  1. POST /api/v1/runner/heartbeat — liveness signal
  2. POST /api/v1/runner/fetch_task — fetch a job
  3. POST /api/v1/runner/update_status — update status
  4. POST /api/v1/runner/upload_log — upload logs
  5. POST /api/v1/runner/upload_artifact — upload job artifacts
  6. GET /api/v1/runner/artifact — fetch a dependency job’s artifacts
  7. GET /api/v1/runner/cache — restore a job’s cache by key
  8. POST /api/v1/runner/upload_cache — save a job’s cache by key

A runner fetches earlier jobs’ artifacts by job name: the server picks the repository and pipeline from the claimed queue record, and matches the name against that job’s serving scope — another build’s artifacts aren’t reachable by a matching name. The scope is the same as for the built-in runner: for a job with needs:, its dependencies; for a job without them, jobs in its own pipeline that have already left artifacts. The server tells the runner the list, in the job itself. A missing archive (the dependency left no artifacts) is 404, a normal outcome for the runner. The limit on a received artifact archive’s size is 500 MB; exceeding it aborts the upload with 400 and an explanation. Runners of earlier versions don’t get dependency artifacts from this server: the job log shows 401, and the job continues without the dependency files. Runners need to be upgraded along with the server.

Cache (the workflow’s cache:) is stored on the server, in the CI data directory: cache/{repo_id}/{key}.tar.gz. The runner gets the key already substituted with variables in the job and returns it as-is; the server picks the directory from the claimed job’s repository, so another repository’s cache under a matching key isn’t reachable. The archive size limit is 500 MB, same as for artifacts.

A runner has no language setting of its own: it receives the job log’s language from the server together with the job and writes all of its lines in it, and asks for the server’s refusals that end up in the log as a reason in the same language. The language is fixed for the job, so changing the server’s language mid-build doesn’t produce a log in two languages. An older runner doesn’t know the language field and writes the log in Russian — with language = "en", runners need to be upgraded along with the server.

The whole protocol is rate-limited separately from the rest of the API, and by runner token rather than address; a runner survives a 429 refusal by retrying. Values and reasons — in Rate limits.

Status, logs, and artifacts are accepted only from the runner handling the job’s current attempt. If a job was canceled or the server wrote it off for exceeding its wait timeout (runner_max_wait), a result arriving afterward from the earlier runner gets 409 Conflict and is discarded; the current attempt isn’t affected. On such a refusal, the runner stops running the job, the same as on cancellation.


SCIM (automated user management)

For integrating with an identity provider (Okta, Microsoft Entra ID, and others):

Action API
List users GET /scim/v2/Users
Create POST /scim/v2/Users
Update PATCH /scim/v2/Users/{id}
Delete DELETE /scim/v2/Users/{id}
Groups GET /scim/v2/Groups

An email address is required

An account is created around an address: without one, notifications won’t go out and account recovery won’t work. GitRiver takes the address in this order:

  1. emails marked primary;
  2. the first element of emails, if none is marked;
  3. userName — but only if it’s itself an address (the usual case for Okta and Entra ID, where sign-in is configured by email).

If there’s no address at all, creation is rejected with 400 invalidValue and an explanation in the detail field. Configure the emails attribute mapping in the provider (or set userName equal to the address) — otherwise external management stops dead on the very first user. The address is checked by the same rule as ordinary registration: name@domain.tld.

Usernames are normalized to GitRiver’s rules

A username in GitRiver is also a path: /{owner}/{repo}, a directory on disk, a Pages site address. So only Latin letters, digits, hyphens, and underscores are allowed in it, 2–39 characters long. The submitted userName is normalized to these rules — other characters (@ and . from the address, non-Latin scripts, spaces) are replaced with _:

userName from the provider Name in GitRiver
a_orlova a_orlova
a.orlova@corp.example.com a_orlova_corp_example_com
admin (taken by a route) scim_admin

An empty userName is rejected with 400 invalidValue: normalizing it would return just the source prefix, and GitRiver would end up with an owner named scim_.

If the normalized name is already taken, a numeric suffix is appended. The create response contains the name that was actually saved — check against that, not against what you sent. Searching with GET /scim/v2/Users?filter=userName eq "…" understands both forms: the provider’s original userName and the normalized name, so a repeated sync doesn’t create a duplicate. This also works inside a compound condition — userName eq "a.orlova@corp.example" and active eq true finds the normalized name the same way.

A group’s path is derived from its name by the same normalization

A group in GitRiver is also a path, /{group}, so a path is derived from the submitted displayName: letters (any alphabet), digits, hyphens, and underscores, up to 255 characters; everything else is replaced with a hyphen.

displayName from the provider Path in GitRiver
Équipe Plateforme équipe-plateforme
team.sales team-sales
Admin (taken by a route) scim-admin

The normalization is the same as for usernames (see “Usernames are normalized to GitRiver’s rules”).

Renaming at the provider also moves the namespace

A PATCH replacing userName (Okta and Entra ID send this when an administrator renames an employee) renames the account — the same action as the “Rename” button in the GitRiver UI.

What moves along with the name:

  • repository addresses — /{new-name}/{repo}, including git clone and git push;
  • the repository and wiki directories on disk;
  • mentions and links built from the owner’s name.

The old name redirects to the new one until someone else claims it — the same as after a manual rename (see “Renaming a user”): clones, published Pages sites, and external links keep working. GET /api/v1/namespaces/{old-name} responds 301 with the new owner’s address. It’s still worth updating clones (git remote set-url origin <new address>): once someone claims the old name, it will lead to them. Warn the employee ahead of time.

The submitted userName is normalized to GitRiver’s rules the same way as at creation, and only then compared to the current name. So a routine sync that sends userName on every profile update doesn’t trigger a rename — a rename happens only if the normalized name differs from the one stored.

What happened Response
Name changed and is free 200, the saved name in the body
Normalized name matches the current one 200, nothing changes
Name taken by another account or group 409 with scimType: uniqueness
userName is empty or not a string 400 with scimType: invalidValue and userName in detail

A numeric suffix is not appended on rename: a taken name is a refusal. If the account got a suffixed name at creation (a_orlova1), a later PATCH with userName returns 409 — free up the base name, or give the employee a different userName in the provider.

Renaming runs first among a PATCH request’s changes: if the name isn’t accepted, none of the request’s other attributes are applied at all.

The provider owns the name. As long as external management is enabled, the name in GitRiver is the provider’s normalized userName, and it’s always compared against that. Two consequences follow:

  • an employee whose account is linked to the provider can’t rename themselves: POST /api/v1/users/{username}/rename returns 403 and says the name is changed in the provider’s console: otherwise the next sync would undo the change. A GitRiver administrator has no such restriction;
  • if the name in GitRiver has drifted from the normalized userName for any reason (the account was created manually, the name got a suffix at creation, an administrator renamed it), a sync will fix that drift.

Drift matters even before a rename: a lookup by userName eq "…" searches by the normalized name, and if it’s drifted, the provider wouldn’t find the employee and would create a duplicate.

What external management leaves in the audit log

Every membership change is recorded in the audit log (GET /api/v1/admin/audit-log), and the record’s content shows where it came from:

What happened op_type Content
Account created create_user username, source
Name changed rename_user from, to, source
Account disabled (active: false or DELETE) deactivate_user source
Account re-enabled activate_user source

The source field in the content distinguishes where the event came from: scim — external management, registration — self-service registration, saml — first sign-in via SAML with auto-registration, oauth — first sign-in via an external OAuth provider with auto-registration, ldap — first sign-in via the LDAP directory, setup — the initial setup wizard. The create_user event is written for every one of these, including when the first directory sign-in happened not on the sign-in page but through git over HTTP or LFS. The actor for these events is the affected account itself.

Disabling isn’t deletion: the account stays, but sign-in is forbidden for it and every issued token stops working. Grants issued to third-party applications via OAuth and OIDC go dark along with them: refreshing a token or reading the profile through them no longer works, so sign-in to such applications “through GitRiver” closes too. The restriction extends to git over SSH as well: the employee’s former key no longer admits them to either the built-in SSH server or gitriver serv from authorized_keys. The key itself stays in the profile — there’s no need to remove it when an employee leaves. Re-enabling (active: true) restores sign-in, and the key starts working again with no need to re-add it.

Filter-based search: what’s supported

A provider looks up an account or group with a list-filter query (GET /scim/v2/Users?filter=…). The full RFC 7644 §3.4.2.2 grammar is supported: the operators eq, ne, co, sw, ew, pr, gt, ge, lt, le, the connectors and, or, not, parentheses, and a sub-filter on a multi-valued attribute (emails[type eq "work" and value eq "…"]).

Resource Filterable attributes
Users id, externalId, userName, displayName, emails (and emails.value, emails.type, emails.primary), active, meta.created, meta.lastModified
Groups id, externalId, displayName, members (and members.value), meta.created, meta.lastModified

Comparison rules:

  • userName, displayName, and emails are compared case-insensitively — this is how RFC 7643 §7 declares these attributes. If GitRiver happens to have two accounts differing only by case, the filter returns both — visible in totalResults;
  • externalId is compared case-sensitively: it’s an opaque key from the provider, and folding case would merge different records;
  • id and members are identifiers: only eq and ne apply to them. Filtering on a value that isn’t a valid identifier returns an empty result, not a refusal;
  • active accepts true/false, as well as "true", "1", "0" — the same rule used to parse active in a request body;
  • meta.created and meta.lastModified point to the same field: in GitRiver’s response, lastModified equals created, so filtering doesn’t diverge from what’s returned;
  • co, sw, ew values are searched as substrings: % and _ inside a value are ordinary characters, not wildcard markers.

Unsupported means a refusal, not an empty result. An unknown attribute, an operator outside the RFC, invalid syntax, an inapplicable combination — all of these return 400 with scimType: invalidFilter and a reason in detail (for example: “filtering users by attribute ‘nickName’ isn’t supported; supported: …”). An empty list for such a query would read to the provider as “no such record” and create a duplicate. An empty filter= means the whole directory, not an empty filter.

A filtered lookup returns a REAL page: totalResults counts every matching record, startIndex and count page through them, and itemsPerPage says how many records this response holds (the provider advances startIndex by it).

Equality filters (eq) are fast and don’t depend on directory size. Substring operators (co, sw, ew) are noticeably slower on a large directory — on one with tens of thousands of records, reserve them for one-off lookups rather than routine sync; providers drive their sync by eq.

The provider-side identifier can be changed

An account’s externalId is set at creation and changed with a PATCH using the path externalId (or the same field in a replacement object with no path). The change is needed after the directory is migrated at the provider: without accepting it, GitRiver would stop finding the record by externalId eq "…", and the provider would create a duplicate. Changing the identifier does not touch the active flag: a disabled account stays disabled. The identifier is unique across the install — trying to attach one to two accounts returns 409 with scimType: uniqueness.

Which groups are under external management

External management controls only the groups handed over to it. Every other group on the install doesn’t exist for it: it’s absent from both GET /scim/v2/Groups and filtered searches, and a direct request for such a group is 404, as for a nonexistent one. This applies to both reads and writes: the refusal code doesn’t reveal what other groups the install has.

A group ends up under external management in one of two ways:

  1. it created the group itself — POST /scim/v2/Groups hands the created group to external management right away, even if the provider didn’t send its own externalId;
  2. a GitRiver administrator handed it over — in the group’s settings, the “External management (SCIM)” block, “Hand over to external management” button. The same block hands it back: the group and its membership stay, only who manages it changes.

When handing over a group, keep inheritance in mind: a group member inherits their role in all of its subgroups. Handing a top-level group to external management hands over its whole subtree’s membership too — if that isn’t what you want, hand over subgroups individually.

The external-management token is long-lived and stored in someone else’s system, so its reach is limited to accounts and the groups a GitRiver administrator has explicitly handed over to it.

If a provider tries to create a group whose name is already taken by a group that hasn’t been handed to it, it gets 409 with an explanation: link the existing GitRiver group, or give the group a different name at the provider. It can’t silently claim someone else’s group.

On upgrade. Groups are considered handed over if they have a provider externalId recorded — this covers everything Okta and Entra ID created with their own identifier. If external management created groups without an externalId, it will stop seeing them after the upgrade: hand them back over with the button in the group’s settings. Easy to check — compare GET /scim/v2/Groups before and after.

A group remembers its provider-side identifier

Entra ID creates a group with its own externalId and later looks it up with GET /scim/v2/Groups?filter=externalId eq "…". GitRiver stores this link and returns externalId when reading the group. It can also be set later — via PATCH with the path externalId (for a group already handed over), or right when the group is handed to external management in its settings. The identifier is unique across the install, and attaching one to two groups returns 409 with scimType: uniqueness.

A handed-over group with no provider identifier is normal: Okta doesn’t always send one. externalId is then absent from the response, and that’s not an error.

A group’s owner survives a membership sweep

SCIM carries no roles: the whole submitted membership is recorded with the developer role. So the “owner” role in a group could only ever come from a GitRiver administrator — for example, when the group was created and its creator became its owner.

A full membership sweep (PATCH with a replace operation on members) sets membership to exactly what was sent, except for rows with the “owner” role — those stay. The same rule applies to a remove operation — it doesn’t remove a named owner either. That way a routine sweep from Okta or Entra ID doesn’t leave the group unmanaged.

What follows from this:

  • in the sweep’s response (members), the owner is present even if the provider didn’t send them — that’s not a mismatch, it’s a preserved role;
  • if the owner is also in the sent membership, they stay owner rather than being demoted to developer;
  • “maintainer” and lower roles are swept away like everyone else’s: only the owner role is protected;
  • to remove an owner from a group, change the owner in the GitRiver UI — it can’t be done through the provider.

Group sync via SAML behaves the same way, and it also leaves alone memberships created by hand: it only removes the ones it created itself.

What the external-management token can and can’t do

The external-management token acts in the name of the HR system, not of an employee, and doesn’t hold GitRiver administrator rights. Its boundaries:

What Can external management do it
Create and disable an account, change its name, address, display name yes
Create a group yes — a group it creates is immediately under its management
Rename a group and set its membership only for groups handed over to it
See a group in the directory and in filtered search only ones handed over to it; the rest don’t exist for it
Claim a group it wasn’t handed no — it’s invisible, and the name is taken: 409 with an explanation
Delete a group only a handed-over one, and only if empty, together with its subgroups — otherwise 409
Add a member with a role above “developer” no — the submitted membership is recorded with role developer, the schema has no role attribute
Remove the owner from a group no — a sweep doesn’t touch rows with the “owner” role
Make an account an instance administrator no — the SCIM schema has no administrator flag, and external management never writes one
Disable the last active administrator no — 400 refusal with the reason in detail

Disabling closes sign-in entirely: password, tokens, OAuth and OIDC grants, SSH access — and nothing inside the product can restore the active flag, only external management itself changes it. To disable the sole administrator, first make a second account an administrator — then disabling the first one will go through.

On deleting groups — the same reasoning and the same rule as in the GitRiver UI: a group with repositories isn’t deleted, and this counts repositories not only in the group itself but in all of its subgroups too — deleting the parent removes them along with it. Keep in mind: Okta, when unlinking an exported group, routinely suggests “delete the group in the app,” and that’s its recommended procedure. Deleting a group in GitRiver can’t be undone — repositories are unlinked from it and all change address at once, and the group’s build variables, quotas, network-address rules, and custom roles all go with it. So move the repositories first, then remove the group at the provider.

An administrator counts as active as long as external management hasn’t disabled them. The same rule has a symmetrical consequence: while there’s only one working administrator, their rights can’t be removed and they can’t be deleted, even if the install has other administrators whose accounts are disabled.

Refusals are visible in the provider’s console

Write operations (POST, PATCH, PUT, DELETE) don’t respond 200 if the change wasn’t applied:

What happened Response
Invalid address, invalid member identifier, unparsable active 400 with scimType: invalidValue and the field name in detail
displayName longer than 255 characters (for a group, also empty) 400 with scimType: invalidValue and the field name in detail
An operation other than add / remove / replace 400 with scimType: invalidSyntax
remove without path 400 with scimType: noTarget
A reference to a nonexistent user in members 400 listing the identifiers that weren’t found
Address already taken by another account 409 with scimType: uniqueness
Disabling the last active administrator 400 with scimType: invalidValue and the reason in detail
Deleting a group that (or whose subgroups) has repositories 409 with the repository count in detail
Creating a group with a name taken by a group not handed to external management 409 with scimType: uniqueness and an explanation in detail
Any request for a group not handed to external management 404
No account or group with that id 404
Storage failure 500

A PATCH is parsed in full before the first write: a request with an invalid value in any operation is rejected without applying any of the rest (RFC 7644 §3.5.2). Attributes GitRiver doesn’t store (name.givenName, title, phoneNumbers) are skipped without a refusal — the provider sends the whole object.

Not applied (logged on the server, response still successful): remove operations on user attributes — an account can’t exist without an address, an active flag, or a login name. For groups, remove does work: by a path filter (members[value eq "…"]), as a list of values, and on the whole members attribute (which then clears membership entirely).

Replacing userName is applied — see “Renaming at the provider also moves the namespace”.

On upgrade. Replacing userName renames the account. The very first sync after the upgrade will rename everyone whose name in GitRiver diverges from the provider’s normalized userName — along with their repository addresses. Before enabling external management after the upgrade, check GET /scim/v2/Users for whose names will change, and warn those employees: the old addresses keep working until the names are claimed, but it’s better to update their clones (git remote set-url).

On upgrade. Existing groups’ displayName in SCIM responses changes from the group’s path (platform-team) to its display name (Platform Team). If your provider maps groups by displayName rather than by id, check the mapping after the upgrade.

Note the difference between adding and replacing membership: add brings in members without touching the rest, while replace on the members attribute sets membership to exactly what the provider sent — including removing anyone the provider doesn’t know about. The one exception is the group’s owner: a full sweep doesn’t remove them (see “A group’s owner survives a membership sweep”).