Sam Jeston
History
| 1 | # Accounts and access |
| 2 | |
| 3 | 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. |
| 4 | |
| 5 | **Source files:** |
| 6 | - `src/web/account.rs`: sign-up, sign-in, sessions, profile, password and SSH keys. |
| 7 | - `src/web/ctx.rs`: session lookup and cookies. |
| 8 | - `src/state.rs`: the permission model. |
| 9 | - `src/validate.rs`: input rules. |
| 10 | - `src/ssh.rs`: SSH authentication. |
| 11 | - `src/web/git_http.rs`: HTTP Basic authentication. |
| 12 | |
| 13 | ## Accounts |
| 14 | |
| 15 | ### Registration |
| 16 | |
| 17 | `GET`/`POST /register` (`account::register_form`, `account::register`). |
| 18 | |
| 19 | - **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. |
| 20 | - **The first account becomes the site administrator** (`users.is_admin = 1`). Register it before the server is reachable by anyone else. |
| 21 | - **Validation** (`src/validate.rs`): |
| 22 | |
| 23 | | Field | Rule | |
| 24 | | --- | --- | |
| 25 | | Username | 1–39 ASCII letters, digits or hyphens. Can't start or end with a hyphen or contain `--`. Not reserved. Unique, case-insensitively | |
| 26 | | Email | Up to 254 characters, no whitespace, a non-empty local part, and a domain containing `.`. Unique, case-insensitively. Not verified by email | |
| 27 | | Password | At least 8 characters and at most 256 bytes | |
| 28 | |
| 29 | 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`. |
| 30 | |
| 31 | - **Afterwards**, the new user is signed in and sent to **Settings → SSH keys** with a welcome message. |
| 32 | |
| 33 | ### Signing in |
| 34 | |
| 35 | `POST /login` (`account::login`): |
| 36 | |
| 37 | - The login field accepts a username or an email (`db::user_by_login`). |
| 38 | - 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." |
| 39 | - `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 `/\`. |
| 40 | - There is no rate limiting, lockout, two-factor authentication or password reset (see [Security](security.md#known-gaps)). |
| 41 | |
| 42 | ### Passwords |
| 43 | |
| 44 | - Hashed with `argon2` 0.5 defaults (Argon2id, 19 MiB of memory, 2 iterations) and a random salt, inside `spawn_blocking`. |
| 45 | - Changing the password (`POST /settings/password`) requires the current password. It signs out every other session of that user and keeps the current one. |
| 46 | |
| 47 | ### Sessions |
| 48 | |
| 49 | | Property | Value | |
| 50 | | --- | --- | |
| 51 | | Cookie | `openhub_session`, `HttpOnly`, `SameSite=Lax`, `Path=/`, 30-day `Max-Age`. `Secure` when `--public-url` starts with `https://` | |
| 52 | | Token | 32 random bytes from the OS RNG, base64url-encoded | |
| 53 | | Storage | Only `sha256(token)` is stored, in `sessions.token_hash`, so a database leak doesn't expose usable cookies | |
| 54 | | Lifetime | Fixed 30 days from sign-in. There's no sliding renewal | |
| 55 | | Validation | `ctx::middleware` hashes the cookie and calls `db::user_by_session`, which ignores expired rows. It skips this for git HTTP paths | |
| 56 | | Sign-out | `POST /logout` deletes the session row and clears the cookie | |
| 57 | |
| 58 | ### Flash messages |
| 59 | |
| 60 | 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. |
| 61 | |
| 62 | ### Profile |
| 63 | |
| 64 | `/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. |
| 65 | |
| 66 | ## SSH keys |
| 67 | |
| 68 | SSH keys are the only credential that can push. |
| 69 | |
| 70 | **Adding a key** (`POST /settings/keys`, `account::add_key` and `validate::ssh_public_key`): |
| 71 | 1. The input must be a single line. Input containing `PRIVATE KEY` is rejected, with a hint to paste the `.pub` file instead. |
| 72 | 2. The line is parsed as an OpenSSH public key by the `ssh-key` crate. |
| 73 | 3. The stored copy is normalised: re-encoded with the comment removed. The comment becomes the default title. |
| 74 | 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. |
| 75 | |
| 76 | **Deleting a key** (`POST /settings/keys/{id}/delete`) only works for the user's own keys. Anything else returns 404. |
| 77 | |
| 78 | **Usage tracking.** `last_used_at` is updated each time the key authenticates, and shown on the keys page. |
| 79 | |
| 80 | **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. |
| 81 | |
| 82 | ## Identity on each channel |
| 83 | |
| 84 | | Channel | How the user is identified | Code | |
| 85 | | --- | --- | --- | |
| 86 | | Browser | Session cookie | `ctx::middleware` → `Ctx.user` | |
| 87 | | SSH (`git push`, `fetch`, `clone`) | Public key fingerprint → `ssh_keys` → `users`. The SSH username, conventionally `git`, is ignored | `SshSession::auth_publickey` | |
| 88 | | HTTP git, public repository | Anonymous. Credentials aren't requested | `git_http::authorize` | |
| 89 | | HTTP git, private repository | HTTP Basic auth: a username or email, plus the account password, checked with Argon2id on every request | `git_http::authorize` | |
| 90 | | HTTP push | Always refused (403) | `git_http::receive_pack`, `info_refs` | |
| 91 | |
| 92 | ## Permission model |
| 93 | |
| 94 | All permission checks go through `AppState::access(repo, user)`, which returns `Access { read, write, admin }`: |
| 95 | |
| 96 | | Who | `read` | `write` | `admin` | |
| 97 | | --- | --- | --- | --- | |
| 98 | | Site administrator (`users.is_admin`) | ✓ | ✓ | ✓ | |
| 99 | | Repository owner | ✓ | ✓ | ✓ | |
| 100 | | Collaborator | ✓ | ✓ | | |
| 101 | | Any other signed-in user | public repositories only | | | |
| 102 | | Anonymous visitor | public repositories only | | | |
| 103 | |
| 104 | ### What each level allows |
| 105 | |
| 106 | | Action | Needs | Enforced in | |
| 107 | | --- | --- | --- | |
| 108 | | View code, history, branches, tags and pull requests; download archives and raw files | `read` | `repo::load_repo` (every repository page) | |
| 109 | | Clone and fetch over SSH or HTTP | `read` | `SshSession::exec_request`, `git_http::authorize` | |
| 110 | | Push over SSH (create, update or delete branches and tags) | `write` | `SshSession::exec_request` | |
| 111 | | Open a pull request, or comment on one | Signed in, plus `read` | `pulls::create`, `pulls::comment` | |
| 112 | | 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` | |
| 113 | | Merge a pull request | `write` | `pulls::merge` (`RepoCtx::require_write`) | |
| 114 | | Delete a branch in the web interface (not the default branch) | `write` | `repo::delete_branch` | |
| 115 | | Change the name, description, visibility or default branch | `admin` | `repo::update_settings` (`RepoCtx::require_admin`) | |
| 116 | | Add or remove collaborators | `admin` | `repo::add_collaborator`, `repo::remove_collaborator` | |
| 117 | | Delete the repository | `admin`, plus typing `owner/name` to confirm | `repo::delete` | |
| 118 | | Create a repository | Signed in | `repo::create` (always owned by the creator) | |
| 119 | |
| 120 | Notes: |
| 121 | |
| 122 | - **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. |
| 123 | - **Collaborators can't be granted read-only access.** The only level is write. |
| 124 | - **There are no organisations or teams.** Every repository belongs to one user. |
| 125 | - **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](operations.md#administration-without-a-ui)). |
| 126 | |
| 127 | ### Private repositories look like missing ones |
| 128 | |
| 129 | A user without `read` should be unable to tell a private repository from one that doesn't exist: |
| 130 | |
| 131 | | Channel | Response when the repository is private and the user lacks access | |
| 132 | | --- | --- | |
| 133 | | Web | 404 page (`load_repo` returns `AppError::NotFound` before any other check) | |
| 134 | | SSH | `ERROR: Repository not found.`, the same as for a missing repository | |
| 135 | | HTTP git, with credentials | 404 `Repository not found` | |
| 136 | | 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](security.md#known-gaps) | |
| 137 | | Profile and dashboard lists | Private repositories are only listed for the owner, collaborators and site administrators (`db::list_owner_repos`) | |
| 138 | | Explore and landing pages | Public repositories only | |
| 139 | |
| 140 | New handlers must load repositories through `repo::load_repo`, which applies this rule, then call `require_write()` or `require_admin()` as needed. |
| 141 | |
| 142 | ## When you change this |
| 143 | |
| 144 | - **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. |
| 145 | - **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. |
| 146 | - **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`. |