Bind invites to a provider, and email them

Wanted shape: the owner signs in with GitHub, everyone invited later signs in
with Google. That was previously a convention, not a boundary -- may_login
checked only that a live row existed, so any enabled provider could satisfy
any invite.

Now an invite is bound to the provider it was issued for. friend@gmail.com
invited for Google cannot be redeemed over GitHub, and there is never a
second, weaker door onto an existing account. The per-request re-check still
passes provider=None, because there the question is only whether the account
is still live.

Two related fixes fell out:

  - upsert_user no longer refreshes `provider` from the login. It is part of
    the identity an admin issued and may_login now enforces it, so overwriting
    it on sign-in would let a successful login silently rewrite the very rule
    that permitted it.
  - a Google invite must be a full email address. A bare username would
    authorise something Google can never return, and the invitee would see
    only "not authorised" with no way to work out why.

INVITE EMAILS. Adding a user now mails them, when SMTP is configured and there
is an address -- for Google there always is, since the identity IS one. The
response reports whether the mail actually went and why not, because "Invited"
alone reads as success while the person waits for an email nobody sent.

The link carries NO token, deliberately. The row already names the exact
identity permitted and Google will not hand us an unverified address, so
possession of the mail is not what authorises anyone: a forwarded invite
grants nothing, nothing expires, and resend is always safe. A signed expiring
token would only buy the ability to withdraw an unused invitation, which
deleting the row already does.

Resend is offered in the panel on anyone who has an address and has never
signed in -- the likeliest reason being that they missed it.

tests/test_invites.py: 29 assertions driving a real SMTP conversation against
a sink server, plus the full provider-binding matrix. 119 tests pass across
four suites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Aryan SinghandClaude Opus 5 committed 2026-08-05 12:05:44 -05:00
1 parent 205e0a44b8
commit da8bf67093
5 files changed
+434 -30

No files matched your search

+37 -9
View File
@@ -267,11 +267,11 @@ deploy quietly serving an unauthenticated download client to the internet.
Two rules, checked in order:
1. **`MUSE_ALLOWED_USERS`** — the bootstrap allowlist. Always admitted. This is
how the first admin gets in when the database is empty, and the recovery
path if someone locks themselves out.
2. **A `users` row that isn't disabled** — how everyone invited later gets in,
with no env change and no redeploy.
1. **`MUSE_ALLOWED_USERS`** — the bootstrap allowlist. Always admitted, on any
enabled provider. This is how the first admin gets in when the database is
empty, and the recovery path if someone locks themselves out.
2. **A `users` row that isn't disabled, on the provider it was invited for** —
how everyone invited later gets in, with no env change and no redeploy.
Anyone matching neither is rejected and the attempt is logged with their
username. The check runs on **every request**, not just at login: an
@@ -280,6 +280,22 @@ authorization check that only runs at sign-in isn't revocation, it's a delay.
The first account to ever sign in becomes **admin**. No bootstrap ceremony, no
default password.
#### Invites are bound to a provider
Invite `friend@gmail.com` for Google and Google is the only way in for that
identity — the same address arriving over a different provider is refused. So
"the owner signs in with GitHub, everyone else with Google" is an enforced
boundary rather than a convention, and there is never a second, weaker door
onto an existing account.
For the same reason a successful login no longer refreshes the stored
`provider`: it is part of the identity an admin issued, so letting a sign-in
rewrite it would let the login quietly rewrite the rule that permitted it.
A Google invite must be the full email address, because a Google identity *is*
one — inviting a bare username would authorise something Google can never
return, and the person would just see "not authorised" with no explanation.
### Setting it up
1. <https://github.com/settings/developers> → **New OAuth App**
@@ -313,9 +329,20 @@ they control and the allowlist is keyed on exactly that string.
Roles are **admin**, **user** and **guest**. Admins get an extra tab.
**Inviting is just adding a row** — no email, no token, no expiry to manage.
Add someone's GitHub username or Google address in the admin panel and they can
sign in. Remove them and they can't.
**The row is the authorisation** — add someone's Google address in the admin
panel and they can sign in. Remove them and they can't.
**The email is a notification, not a credential.** If SMTP is configured they
also get a message telling them they have access and which account to use. That
link carries **no token**, deliberately: the row already names the exact
identity permitted, and Google will not hand muse an unverified address — so
possession of the email is not what lets anyone in. Which means a forwarded or
leaked invite grants nothing, nothing expires, and **resend is always safe**.
The admin panel offers resend on anyone who hasn't signed in yet.
The alternative — a signed, expiring token — buys you the ability to withdraw
an invitation before it's used. Deleting the row does that already, so the
token would be machinery guarding something already guarded.
### Quotas
@@ -511,7 +538,8 @@ package page → Package settings → Change visibility.
| `PATCH /api/me` | your own email address and notification toggle |
| `GET /api/health` | slskd reachability, mount writability |
| `GET /api/admin/users` | list users with usage |
| `POST /api/admin/users` | invite |
| `POST /api/admin/users` | invite, and email them if SMTP is set up |
| `POST /api/admin/users/{u}/resend` | send the invitation again |
| `PATCH /api/admin/users/{u}` | role, quotas, disable |
| `DELETE /api/admin/users/{u}` | remove (jobs retained) |
| `GET /api/admin/system` | CPU, memory, disk, slskd, library totals |