Security & access
Rilbo's access model is deliberately small, and the most important thing about it is the sentence below. Read it before you invite anyone to a shared install.
The trust boundary is the workspace
Everyone who can sign in to a workspace can read every issue, project, cycle, comment, attachment and report in it. Everyone except a viewer can also change them. There is no per-team, per-project or per-issue access control.
Teams organise work — they decide issue key prefixes, board defaults, cycles and templates. They are not a security boundary, and team membership is not an access grant. A user who belongs to no team at all still reads and writes every team's issues.
The same applies to the team-local role (lead / member / viewer) shown under Team → Members. It orders the member list and records who leads the team; it is not consulted when deciding whether a request is allowed. A user with the workspace role member who is listed as a team viewer can still edit that team's issues, because the workspace role is the one that counts.
If you need a hard boundary between two groups of people, run two workspaces. A workspace is a single SQLite file served by its own process, so two installs share nothing at all — no rows, no tokens, no sessions. That is the isolation mechanism Rilbo supports, and it is a far stronger one than in-app partitioning would be.
What the roles actually do
The workspace role is the only role that gates anything:
| Role | Can do |
|---|---|
viewer | Read-only. Every unsafe HTTP method is refused across the UI and the JSON API (/logout excepted). |
member | Read and write everything, in every team. Held back from admin-only operations. |
admin, owner | Everything a member can do, plus users, team membership, API tokens, workspace settings, the audit log, automation rules and inbound webhooks. |
The line between member and admin is drawn by reversibility and blast radius, not by importance: an operation is admin-only when it cannot be undone, when it changes workspace-wide configuration, or when it acts on a set of issues the server chooses rather than the caller names. Hard-deleting an issue, deleting a label, setting WIP limits and rolling a cycle over are admin-only for that reason; the soft archive is not.
Two things are additionally restricted to whoever made them, or an admin: an attachment may be deleted only by its uploader, and a saved view may be edited or deleted only by its owner. Both are per-object ownership rules. Neither is a team boundary, and neither restricts reading the issue an attachment hangs on.
API tokens carry their own scope (read / write / admin). A token bound to a user is capped at that user's role, re-evaluated on every request: a write token bound to a viewer cannot write, and demoting a user immediately narrows every token bound to them. A token bound to a user who has been deactivated or deleted falls back to read.
An unbound token is not capped, by design — it is a workspace credential rather than a person, so its granted scope is what it gets. Issue unbound tokens only where you would accept the equivalent of a member (or admin) account.
MCP is authorized by token scope rather than by the role gate above: the /mcp endpoint is exempt from the session middleware and reaches its decision from the token's effective scope, so the caps in this section are what apply to it. MCP over stdio is not role-gated at all — a stdio client already has the database file open, so withholding rows from it would buy nothing.
Saved views are the one per-object visibility rule
A saved view has a scope of private, team or workspace, and it is enforced: a private view is visible to its owner alone, a team view to members of that team, and a workspace view to any authenticated caller. A view you cannot see returns 404, identical to one that does not exist.
This is view visibility, not tenancy. A team-scoped view hides the saved filter — its name, its query, its place in your sidebar — from people outside the team. It does not hide the issues the filter matches: those are workspace data, and anyone signed in can find them through search, the board, or the API. Treat the scope as tidiness for the sidebar, not as a control that protects anything.
Network egress
Rilbo makes zero outbound network connections unless you explicitly configure a notification channel. Not telemetry, not analytics, not a crash reporter, not an update check, not a licence activation call. A fresh install, left alone, talks to nothing.
That is easy to claim and hard to trust, so we do two things about it. We check it in the source: a build step on every change fails if any code outside the five notification sinks can open a socket, so this page cannot quietly drift away from the software. And we check it at runtime: CI starts the release image on a network with no route off it and requires an outbound connection to fail. Publishing a release image is gated on both, so if egress ever reopened the build would go red and the image would not ship. You can run the same profile yourself — see running with egress blocked below.
Where the app can connect, and to what
Every outbound connection Rilbo can make belongs to a notification channel, and every channel is off until someone turns it on. Nothing here is reachable on a default install.
| Channel | Destination | Turned on by |
|---|---|---|
| Webhook | The exact URL you configure — nothing else | A user adding a webhook channel |
| Slack | hooks.slack.com, on the incoming-webhook URL you paste in | A user adding a Slack channel |
| Telegram | api.telegram.org, fixed and compiled in | The operator setting TELEGRAM_TOKEN, and a user adding a Telegram channel with a chat id |
Your SMTP relay, at the host and port in SMTP_RELAY | The operator setting SMTP_RELAY | |
| Web Push | The push endpoint the subscriber's browser supplies — their browser vendor's service, not one you pick | A user subscribing to push in their browser |
That table is written so you can allowlist instead of opening egress. Four of the five destinations are known in advance and can be pinned to a single host at your firewall or egress proxy. Web Push is the exception, and honestly so: the endpoint arrives with each subscription, so there is no hostname to pre-approve. If narrow egress rules matter more than push notifications, leave Web Push unconfigured.
Webhook, Slack and Web Push targets are user-supplied, so they pass the SSRF guard described under the threat model below: http/https only, the resolved address is pinned for the duration of the request, redirects are not followed, and environment proxy variables are ignored. Telegram's endpoint is fixed, so there is nothing to guard. The SMTP relay is deliberately not address-checked — it is your own infrastructure, and a relay on 10.0.0.25 is an ordinary thing to have.
When a send cannot leave the host, it is recorded rather than dropped. A failure that might clear on its own — the network is down, the receiver is having a bad day — is retried on a widening backoff and dead-lettered after eight attempts. A failure that clearly will not clear, such as a destination that does not resolve, is dead-lettered immediately rather than retried at something that cannot work. Either way the error ends up on the delivery record, visible under Settings → Notifications, and the delivery worker neither spins nor wedges the process. CI asserts that too, with a channel pointed at a deliberately unreachable address.
What is not there at all
These are the things a self-hosted tracker usually phones home for. Rilbo does none of them, and the machinery for them does not exist in the binary:
- No telemetry, analytics or crash reporting. Nothing counts your issues, your users or your keystrokes.
- No update check. Not automatic, not opt-in. Check for updates on Settings is a plain link you click, and it carries your version in the URL fragment (
https://rilbo.com/updates#v=0.8.0). Browsers do not send the fragment to any server, so your version cannot appear in our logs even when you do click it. The comparison runs in your browser, on a static page whose release list is baked in at build time. - No licence activation. Licences are signed files verified offline against a key compiled into the binary. There is no licence server to call, and Rilbo works air-gapped.
- No remote assets. The UI serves its own CSS and JavaScript from the binary and uses system fonts — no CDN, no webfont service. The Content-Security-Policy pins every script, fetch and XHR to the app's own origin (
connect-src 'self').
One honest caveat, because it is the only third-party request anywhere in the picture. Avatars come in four kinds, and two of them point at somebody else's server: a Gravatar avatar resolves to www.gravatar.com, and a URL avatar to wherever the user pointed it. In both cases the fetch is made by that user's browser, not by Rilbo — the server computes the Gravatar hash locally and never makes the request itself, which is why blocking the server's egress does not affect it. The other two kinds, upload and initials, involve no third party at all.
Be aware that there is no server-side switch to disable the first two today. Because the request originates in the browser, the lever that would stop it is a policy on your clients, not on the Rilbo host.
Running with egress blocked
The claim above is about the software. If you would rather not take our word for it, enforce it at the network layer — then it holds whatever the software does. Two supported profiles ship with Rilbo, both additive opt-ins that leave an existing deployment untouched:
Docker — deploy/compose.no-egress.yml is a complete, self-contained stack. Rilbo runs on a network marked internal, which has no gateway and therefore no route off it; a Caddy reverse proxy straddles that network and an edge network, terminating TLS and passing traffic inward. Traffic flows in; nothing flows out. The file also points the app's DNS resolver at its own loopback, so external name resolution fails as well as the connect — a DNS query carries its payload in the question, so a resolvable name is an exfiltration channel even when the connection that follows it cannot complete. This is the exact profile CI runs on every pull request.
systemd — deploy/rilbo.service carries a commented no-egress block: IPAddressDeny=any with IPAddressAllow=localhost puts a kernel filter on the service's sockets, and RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX stops that filter being stepped around at a lower layer. Uncomment three lines. It composes with the hardening already in the unit — NoNewPrivileges, ProtectSystem=strict, ProtectHome, PrivateTmp — which constrain the filesystem and privilege surface while these constrain the network one.
Under either profile the app, the API, the MCP server and in-app notifications all work exactly as before; what stops is every delivery sink that leaves the machine. To keep one channel, do not open egress: add its single destination from the table above as an allowlist rule. That is the narrow rule worth writing.
Both files ship in the source tree under deploy/, and each carries the full reasoning in its comments — including which channels dead-letter under it and what the DNS caveat costs you.
Release integrity
Releases that publish Minisign signatures can be checked before installation. Follow the download verification procedure to authenticate the checksum manifest, validate the archive checksum, and verify the archive's own signature with Rilbo's published public key.
Threat model
What Rilbo is built to withstand:
- Unauthenticated network access. In
passwordandtoken_onlymodes every gated route requires a credential. Insingle_usermode the server refuses to start on a non-loopback bind, because the UI and API would be unauthenticated;RILBO_ALLOW_INSECURE=1overrides it and logs a warning. - A hostile browser origin. Session cookies are
HttpOnlyandSameSite=Lax, and an unsafe write authorized by ambient authority — a session cookie, or thesingle_userfallback — must additionally carry same-origin provenance. Bearer-token clients are exempt from that check, which exists for browsers. - Credential guessing. Passwords are argon2-hashed, login is throttled, and TOTP two-factor is available on every plan.
- Outbound request abuse. Notification targets (webhook, Slack, Web Push) are SSRF-guarded against private, loopback and cloud-metadata addresses. The resolved address is pinned for the request and redirects are not followed, so a hostile DNS answer cannot move the target after the check.
- A stolen narrow token. Token scopes and user binding limit what a leaked token reaches; personal endpoints refuse an unbound token outright.
What it explicitly does not withstand:
- A hostile authenticated user. Anyone you invite is inside the boundary. Rilbo records what they did in the audit log; it does not stop them doing it.
- Separation between teams, projects or customers in one workspace. There is none. See above.
- A reader who should not see some subset of the data. The only supported answers are
viewer(sees everything, changes nothing) or a second workspace.
This section is the stated baseline. If you are auditing Rilbo and find a behaviour that contradicts it — in either direction — that is a bug worth reporting to [email protected].