samjeston / openhub Public
Sam Jeston Working vertical 9aa3c45 · History
146 lines· 9.7 KB ·Markdown

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_register holds 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-insensitively
    Email Up to 254 characters, no whitespace, a non-empty local part, and a domain containing .. Unique, case-insensitively. Not verified by email
    Password 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 to RESERVED.

  • 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."
  • next takes the user back to the page that required sign-in. web::safe_next only 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 argon2 0.5 defaults (Argon2id, 19 MiB of memory, 2 iterations) and a random salt, inside spawn_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):

  1. The input must be a single line. Input containing PRIVATE KEY is rejected, with a hint to paste the .pub file instead.
  2. The line is parsed as an OpenSSH public key by the ssh-key crate.
  3. The stored copy is normalised: re-encoded with the comment removed. The comment becomes the default title.
  4. 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 with write can 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 admin on 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): add something to RESERVED in src/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_repos and db::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.