Accounts and access
This page explains how people get accounts, how they prove who they are on each channel (browser, SSH and HTTP git), and what they can then do with each repository.
Source files:
src/web/account.rs: sign-up, sign-in, sessions, profile, password and SSH keys.src/web/ctx.rs: session lookup and cookies.src/state.rs: the permission model.src/validate.rs: input rules.src/ssh.rs: SSH authentication.src/web/git_http.rs: HTTP Basic authentication.
Accounts
Registration
GET/POST /register (account::register_form, account::register).
-
When it's allowed. Registration is open unless
--disable-registration(OPENHUB_DISABLE_REGISTRATION=true) is set. Even then, the first account can always be created, so a new instance can't lock itself out.account::can_registerholds the rule, and the landing and sign-in pages use it to show or hide the sign-up link. -
The first account becomes the site administrator (
users.is_admin = 1). Register it before the server is reachable by anyone else. -
Validation (
src/validate.rs):Field Rule Username 1–39 ASCII letters, digits or hyphens. Can't start or end with a hyphen or contain --. Not reserved. Unique, case-insensitivelyEmail Up to 254 characters, no whitespace, a non-empty local part, and a domain containing .. Unique, case-insensitively. Not verified by emailPassword At least 8 characters and at most 256 bytes Reserved usernames are names that would clash with top-level routes:
admin,api,assets,explore,favicon.svg,git,help,login,logout,new,register,root,settings,signin,signup,static,system. Adding a top-level route means adding its first path segment toRESERVED. -
Afterwards, the new user is signed in and sent to Settings → SSH keys with a welcome message.
Signing in
POST /login (account::login):
- The login field accepts a username or an email (
db::user_by_login). - A password is verified with Argon2id even when the account doesn't exist, against
DUMMY_HASH. That way, response timing doesn't reveal which accounts exist. The error message is always "Incorrect username or password." nexttakes the user back to the page that required sign-in.web::safe_nextonly accepts same-site paths: it must start with/, but not//or/\.- There is no rate limiting, lockout, two-factor authentication or password reset (see Security).
Passwords
- Hashed with
argon20.5 defaults (Argon2id, 19 MiB of memory, 2 iterations) and a random salt, insidespawn_blocking. - Changing the password (
POST /settings/password) requires the current password. It signs out every other session of that user and keeps the current one.
Sessions
| Property | Value |
|---|---|
| Cookie | openhub_session, HttpOnly, SameSite=Lax, Path=/, 30-day Max-Age. Secure when --public-url starts with https:// |
| Token | 32 random bytes from the OS RNG, base64url-encoded |
| Storage | Only sha256(token) is stored, in sessions.token_hash, so a database leak doesn't expose usable cookies |
| Lifetime | Fixed 30 days from sign-in. There's no sliding renewal |
| Validation | ctx::middleware hashes the cookie and calls db::user_by_session, which ignores expired rows. It skips this for git HTTP paths |
| Sign-out | POST /logout deletes the session row and clears the cookie |
Flash messages
One-off messages shown after a redirect, such as "Profile updated.", are set by web::redirect_flash. They are stored in the openhub_flash cookie as base64url kind\nmessage, where kind is success, error or info. The middleware decodes the cookie into Ctx.flash and clears it on the next response, unless that response sets a new one.
Profile
/settings/profile edits the display name (up to 100 characters), email and bio (up to 500 characters). Avatars are generated from the username, as a coloured initial (fmt::avatar). No images are uploaded or fetched.
SSH keys
SSH keys are the only credential that can push.
Adding a key (POST /settings/keys, account::add_key and validate::ssh_public_key):
- The input must be a single line. Input containing
PRIVATE KEYis rejected, with a hint to paste the.pubfile instead. - The line is parsed as an OpenSSH public key by the
ssh-keycrate. - The stored copy is normalised: re-encoded with the comment removed. The comment becomes the default title.
- The SHA-256 fingerprint must not already be registered, by this user or anyone else. Because a key belongs to at most one account, the key alone identifies the user during SSH authentication.
Deleting a key (POST /settings/keys/{id}/delete) only works for the user's own keys. Anything else returns 404.
Usage tracking. last_used_at is updated each time the key authenticates, and shown on the keys page.
Key types. Ed25519 is recommended and is what the smoke test uses. Other algorithms that both the ssh-key parser and russh support also work.
Identity on each channel
| Channel | How the user is identified | Code |
|---|---|---|
| Browser | Session cookie | ctx::middleware → Ctx.user |
SSH (git push, fetch, clone) |
Public key fingerprint → ssh_keys → users. The SSH username, conventionally git, is ignored |
SshSession::auth_publickey |
| HTTP git, public repository | Anonymous. Credentials aren't requested | git_http::authorize |
| HTTP git, private repository | HTTP Basic auth: a username or email, plus the account password, checked with Argon2id on every request | git_http::authorize |
| HTTP push | Always refused (403) | git_http::receive_pack, info_refs |
Permission model
All permission checks go through AppState::access(repo, user), which returns Access { read, write, admin }:
| Who | read |
write |
admin |
|---|---|---|---|
Site administrator (users.is_admin) |
✓ | ✓ | ✓ |
| Repository owner | ✓ | ✓ | ✓ |
| Collaborator | ✓ | ✓ | |
| Any other signed-in user | public repositories only | ||
| Anonymous visitor | public repositories only |
What each level allows
| Action | Needs | Enforced in |
|---|---|---|
| View code, history, branches, tags and pull requests; download archives and raw files | read |
repo::load_repo (every repository page) |
| Clone and fetch over SSH or HTTP | read |
SshSession::exec_request, git_http::authorize |
| Push over SSH (create, update or delete branches and tags) | write |
SshSession::exec_request |
| Open a pull request, or comment on one | Signed in, plus read |
pulls::create, pulls::comment |
| Edit a pull request's title or body, close it or reopen it | The pull request's author, or write |
pulls::edit, pulls::close_pull, pulls::set_state |
| Merge a pull request | write |
pulls::merge (RepoCtx::require_write) |
| Delete a branch in the web interface (not the default branch) | write |
repo::delete_branch |
| Change the name, description, visibility or default branch | admin |
repo::update_settings (RepoCtx::require_admin) |
| Add or remove collaborators | admin |
repo::add_collaborator, repo::remove_collaborator |
| Delete the repository | admin, plus typing owner/name to confirm |
repo::delete |
| Create a repository | Signed in | repo::create (always owned by the creator) |
Notes:
- Opening a pull request needs only
read, but both branches must already exist in the repository, and only people withwritecan push branches. So on a public repository, any signed-in user can open a pull request between existing branches and comment on it. They can't change any code. - Collaborators can't be granted read-only access. The only level is write.
- There are no organisations or teams. Every repository belongs to one user.
- The site administrator has no special pages. Their power is
adminon every repository. Promoting or demoting users, or removing accounts, is done in SQL (see Operations).
Private repositories look like missing ones
A user without read should be unable to tell a private repository from one that doesn't exist:
| Channel | Response when the repository is private and the user lacks access |
|---|---|
| Web | 404 page (load_repo returns AppError::NotFound before any other check) |
| SSH | ERROR: Repository not found., the same as for a missing repository |
| HTTP git, with credentials | 404 Repository not found |
| HTTP git, without credentials | 401 with a Basic challenge, while a missing repository gets 404. This is a known gap, listed under Security → Known gaps |
| Profile and dashboard lists | Private repositories are only listed for the owner, collaborators and site administrators (db::list_owner_repos) |
| Explore and landing pages | Public repositories only |
New handlers must load repositories through repo::load_repo, which applies this rule, then call require_write() or require_admin() as needed.
When you change this
- New top-level route (
/something): addsomethingtoRESERVEDinsrc/validate.rs. An existing user with that name would shadow the route, or the route would shadow the user, so check the database. - New permission level, such as read-only collaborators: change
Access,AppState::access, the collaborators table (via a migration), the settings page,db::list_owner_reposanddb::list_member_repos, then update both tables above. - New credential type, such as tokens: add it to the identity table, keep pushes on SSH unless a decision is made otherwise, and extend
scripts/smoke-test.sh.