package idear import ( "context" "errors" "fmt" "log/slog" "net/http" "strings" "time" "github.com/carlosframework/rastrillo/sessions" "gorm.io/gorm" ) // defaultInviteTTL is how long an invitation stays redeemable when the // app does not say. Seven days: long enough to survive a weekend and a // forwarded mail, short enough that a link found in an old inbox is // already dead. Round 1 of the bake-off penalised immortal invitation // tokens; this is that finding applied before it is earned twice. const defaultInviteTTL = 7 * 24 * time.Hour // Config configures New. DB is required; everything else has a // serviceable default. type Config struct { // DB is the app's database, as *gorm.DB — d.G from rastrillo/db. // idear's migrations (Schema, merged into the app's BootSchema) // must already be applied before any method runs. // // It is expected to be rastrillo/db's split pool: one writer // connection, several readers, routed per statement. That routing // is why every mutation below funnels through tx and why nothing // inside a transaction may touch rs.cfg.DB — see tx. DB *gorm.DB // OpenSignUp admits any verified address at RoleMember with no // invitation. It is read by the admission adapters, not by the // store: the roster itself never decides policy it was not asked // about. OpenSignUp bool // InviteTTL is how long an invitation Invite mints stays // redeemable. Default defaultInviteTTL. InviteTTL time.Duration // Subject resolves the viewer's session Subject from a request. // Default: sessions.Current(r).Subject. It exists as an override // for an app whose viewer arrives some other way; it reads a // session the caller must ALREADY have resolved, so idear's // middleware must be mounted inside the app's session guard. Subject func(*http.Request) (string, bool) // EmailForSubject resolves a session Subject to the address the // APP knows that subject by. It is OPTIONAL, and it buys exactly // one thing: reconciliation (POST /invitations/{token}) can then // require the invitation to have been issued to this viewer's // address — the same rule admission applies. // // It is not consulted under keymail, where the Subject IS the // verified address and idear answers the question itself. It is // for the PASSWORD path, where the Subject is an opaque decimal // user id that only the app's own user table can resolve: // // EmailForSubject: func(ctx context.Context, subject string) (string, error) { // id, err := strconv.ParseInt(subject, 10, 64) // if err != nil { // return "", nil // } // var u User // switch err := g.WithContext(ctx).Where("id = ?", id).Take(&u).Error; { // case errors.Is(err, gorm.ErrRecordNotFound): // return "", nil // case err != nil: // return "", err // } // return u.Email, nil // } // // LEFT NIL, PASSWORD RECONCILIATION TRUSTS POSSESSION OF THE // TOKEN ALONE. idear cannot then tell whose address an invitation // was issued to relative to the viewer, so any signed-in ORPHAN — // someone with an app user row and no member row, which is the // create-succeeded-then-member-write-failed case and the // claim-race loser — may spend ANY live token they get hold of, // at whatever role it carries. That population is small and not // freely manufacturable, but a token is not hard to come by: with // HandlerConfig.Deliver nil it goes into a browser flash cookie, // and a token in a URL rides into history, referrers and logs. // Set this hook and that redemption is refused. // // THE CONTRACT. Return the address, or "" WITH A NIL ERROR when // the subject resolves to nobody — idear refuses the redemption // in that case, because a subject it cannot place must not be // admitted on a token alone. A non-nil error is a STORAGE // FAILURE, answered 500 and never rendered as a policy refusal. // The returned address is normalised before it is compared, so // the app may hand back whatever spelling its own table holds. EmailForSubject func(ctx context.Context, subject string) (string, error) // NotFound answers a request from someone who is not an active // member. It MUST be the same renderer the app gives chi's own // NotFound: an app with a custom 404 page and idear's default // http.NotFound produces two distinguishable 404s, and that delta // is a membership oracle. Default http.NotFound. NotFound func(http.ResponseWriter, *http.Request) // Forbidden answers a member who may see a page but may not act on // it. Default: 403 with plain text. Forbidden func(http.ResponseWriter, *http.Request) Logger *slog.Logger } // Roster is the store: who is in this instance, at what role, and who // may change that. Build exactly one per process (New) and share it — // it holds no per-request state. // // Every mutation below is ONE transaction, and every invariant is // enforced INSIDE the transaction that maintains it. An invariant // checked outside its transaction is not an invariant: it is a // prediction, and a concurrent writer is under no obligation to honour // it. That is the whole reason this type exists rather than a handful // of queries at the call sites. type Roster struct { cfg Config } // New validates cfg and returns a ready *Roster. func New(cfg Config) (*Roster, error) { if cfg.DB == nil { return nil, errors.New("idear: Config.DB is required") } if cfg.InviteTTL == 0 { cfg.InviteTTL = defaultInviteTTL } if cfg.Subject == nil { cfg.Subject = func(r *http.Request) (string, bool) { sess, ok := sessions.Current(r) if !ok || sess.Subject == "" { return "", false } return sess.Subject, true } } if cfg.NotFound == nil { cfg.NotFound = http.NotFound } if cfg.Forbidden == nil { cfg.Forbidden = func(w http.ResponseWriter, r *http.Request) { http.Error(w, "Forbidden", http.StatusForbidden) } } if cfg.Logger == nil { cfg.Logger = slog.Default() } return &Roster{cfg: cfg}, nil } // OpenSignUp reports whether this instance admits any verified address // at RoleMember with no invitation. The admission adapters read it; // the store never does. func (rs *Roster) OpenSignUp() bool { return rs.cfg.OpenSignUp } // InviteTTL is how long a freshly minted invitation stays redeemable. func (rs *Roster) InviteTTL() time.Duration { return rs.cfg.InviteTTL } // now is the one clock the store reads, and it is UTC on purpose — // twice over. // // The obvious reason is that every row is UTC. The second is a trap: // timestamps reach SQLite through the driver as time.Time.String(), // and String() appends " m=+0.000000001" to any time that still // carries a monotonic reading. A stored value with that suffix breaks // the text comparison the CAS in Accept depends on — "expires_at > ?" // would compare a monotonic-tagged string against a plain one and // answer nonsense. time.Now().UTC() strips the monotonic reading; // time.Now() alone does not, and neither does Add on top of it. func (rs *Roster) now() time.Time { return time.Now().UTC() } // tx runs fn in one transaction. // // Inside fn, use ONLY tx. A statement issued against rs.cfg.DB from // inside fn does not join the transaction: it goes to the pool whose // single writer connection this transaction is already holding, waits // for a connection that cannot be released until fn returns, and // HANGS — it does not error. If a test of this package ever hangs, // that is the first thing to look for. func (rs *Roster) tx(ctx context.Context, fn func(tx *gorm.DB) error) error { return rs.cfg.DB.WithContext(ctx).Transaction(fn) } // normalizeEmail is the one spelling of an address idear stores or // compares. Addresses arrive from forms and identity plugins with // stray whitespace and arbitrary case, and an invitation whose Email // matches only when the invitee retypes the capitalisation they were // sent is not a working invitation. Every write and every comparison // goes through here so both sides are normalised the same way. func normalizeEmail(email string) string { return strings.ToLower(strings.TrimSpace(email)) } // normalizeSubject is the one spelling of a session Subject idear // stores or compares. Every Subject WRITE goes through it, and so does // the one place a Subject is read back (BySubject), because a // canonical form applied to only one side of a comparison is worse // than none: it silently stops matching. // // It is not normalizeEmail under another name, even though the two // bodies agree today. They canonicalise different things for different // reasons, and a future change to how ADDRESSES are folded must not // silently rewrite what a SUBJECT is — a Subject is the join to the // app's own identity, and changing its spelling orphans every existing // row. // // THE FAILURE IT EXISTS TO PREVENT, which is not hypothetical. Under // keymail the Subject IS the verified address, and auth mints it as // the address THE VISITOR TYPED: auth/handlers.go's admit does // sessions.Session{Subject: id.Address}, and Identity.Address comes // from keymaildev/signin, whose SplitAddress lowercases only the // DOMAIN and deliberately preserves the local part's case // (signin.go:57-74; flow.go:164 stores the raw typed string). So a // first arrival who types "Alice@Corp.Test" — which is what an iOS // keyboard capitalises by default — would claim the instance under a // lowercased Subject, then sign in successfully forever and 404 on // every guarded route forever, /members included, unable to invite // anyone. The claim is spent and the instance needs database surgery. // // Lowercasing BOTH sides is the fix rather than storing the raw typed // string, because raw storage still lets one human hold two rows // ("alice@" and "Alice@") past a unique index that cannot see they are // the same person. signin itself compares addresses with // strings.EqualFold (flow.go:227), so case-insensitive is the // framework's own notion of address equality. // // It is a no-op on password's subjects, which are decimal ids. func normalizeSubject(subject string) string { return strings.ToLower(strings.TrimSpace(subject)) } // forbidden builds an ErrForbidden carrying reason, so a log line can // say what was refused while callers still test with errors.Is. func forbidden(format string, args ...any) error { return fmt.Errorf("%w: %s", ErrForbidden, fmt.Sprintf(format, args...)) } // checkInviteRole is the role gate Invite and SetRole share. // // RoleOwner is refused OUTRIGHT here, on every path, for every actor — // including an actor who IS the Owner. Ownership moves only by // Transfer, which is the only operation that demotes the outgoing // Owner in the same transaction as it promotes the incoming one, and // therefore the only one that keeps "exactly one Owner" true at every // commit boundary. Any other route to RoleOwner is a second Owner. // // The second rule is subtler and closes an escalation: the granted // role must rank STRICTLY BELOW the actor's. An Admin may invite or // set only Member, because an Admin who could mint a peer Admin has // escalated — MayActOn refuses acting on an equal rank, so the new // Admin would be beyond the granter's reach, and beyond the reach of // every other Admin too. "Admins manage Members only" (design spec §5) // has to hold for creation as well as for management, or it holds for // neither. func checkInviteRole(actor *Member, role Role) error { if !role.Valid() { return fmt.Errorf("%w: %q", ErrInvalidRole, string(role)) } if role == RoleOwner { return forbidden("ownership moves only by Transfer, never by grant") } if rank(role) >= rank(actor.Role) { return forbidden("a %s may not grant %s", actor.Role, role) } return nil } // loadMember re-reads one member by id inside tx. Every mutation that // takes an *Member argument re-reads it through here rather than // trusting the struct it was handed: that struct was read before the // transaction opened, so its Role and DeactivatedAt are a claim about // the past. The row inside the transaction is the fact. func loadMember(tx *gorm.DB, id int64) (*Member, error) { var m Member if err := tx.Where("id = ?", id).Take(&m).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrNotFound } return nil, err } return &m, nil } // IsEmpty reports whether the roster has ZERO ROWS — not zero ACTIVE // rows. A roster whose members have every one been deactivated is not // empty, and must not reopen the claim: doing so would hand a stranger // Owner of an instance full of dormant data. // // It is advisory, and answered from the read pool, so it may lag a // concurrent Claim by one WAL snapshot. Nothing depends on it being // current: Claim re-asks the same question inside its own transaction, // which is where the answer is binding. func (rs *Roster) IsEmpty(ctx context.Context) (bool, error) { var n int64 if err := rs.cfg.DB.WithContext(ctx).Model(&Member{}).Count(&n).Error; err != nil { return false, err } return n == 0, nil } // Claim makes the first arrival the Owner of an unclaimed instance. // // It succeeds only when idear_members holds ZERO ROWS — not zero // ACTIVE rows — and the emptiness test and the insert are ONE // statement, so nothing can come between them. Two concurrent first // signups therefore produce exactly one Owner; the loser gets // ErrOwnerExists and is an orphan — a user row in the app with no // membership — which the signed-in reconciliation route exists to heal // (design spec §5). That is a designed path, not an accident. func (rs *Roster) Claim(ctx context.Context, subject, email, name string) (*Member, error) { subject = normalizeSubject(subject) if subject == "" { return nil, fmt.Errorf("%w: Claim needs a non-empty subject", ErrInvalidSubject) } m := &Member{ Subject: normalizeSubject(subject), Email: normalizeEmail(email), Name: strings.TrimSpace(name), Role: RoleOwner, } now := rs.now() err := rs.tx(ctx, func(tx *gorm.DB) error { // One statement, so the emptiness test and the insert cannot // be separated by anything at all — not by another // transaction, and not by another PROCESS. A count followed // by an insert is also correct here, but only because // rastrillo/db caps the writer pool at one connection; a // second process on the same file (a rolling deploy, a cron // worker) breaks that and the pair degrades to // SQLITE_BUSY_SNAPSHOT — no double Owner, but an opaque // driver error where the caller is owed ErrOwnerExists. // Correctness that depends on an unrelated setting in another // package is what breaks two releases later. res := tx.Exec(`INSERT INTO idear_members (subject, email, name, role, deactivated_at, created_at, updated_at) SELECT ?, ?, ?, ?, NULL, ?, ? WHERE NOT EXISTS (SELECT 1 FROM idear_members)`, m.Subject, m.Email, m.Name, m.Role, now, now) if res.Error != nil { return res.Error } // Zero rows means the NOT EXISTS failed: the roster has rows. // ALL rows, not just active ones — a fully deactivated roster // is not empty and must not reopen the claim. if res.RowsAffected != 1 { return ErrOwnerExists } // Read the row back for its id and timestamps. Safe inside // this transaction for the same reason Accept's read-back is: // the insert above already claimed it. return tx.Where("subject = ?", m.Subject).Take(m).Error }) if err != nil { return nil, err } return m, nil } // Invite mints an invitation to join at role and returns the plaintext // token EXACTLY ONCE — only its SHA-256 digest is stored, so a leaked // database yields no usable links and idear cannot re-send the old one // (invite again instead). // // actor must be an active member of at least Admin rank, and role must // rank strictly below actor's; RoleOwner is refused for everyone. See // checkInviteRole. The actor is re-read inside the transaction, so an // admin deactivated a moment ago cannot still hand out invitations. // // RE-INVITING SUPERSEDES. Any invitation for the same address that is // still unaccepted and unrevoked is REVOKED in this same transaction, // so an address has at most one live invitation at a time. That is not // tidiness — it is the only semantics an admin would predict, and // without it the two identity paths disagree about which of several // coexisting invitations is spent: // // - keymail redeems by address, and acceptByAddress takes the OLDEST // redeemable row. Re-inviting Alice at the corrected higher role // would be silently ignored, and re-inviting her at a corrected // LOWER role would leave the stale higher one live for her to // escalate past the admin's intent. // - password redeems by token, so the invitee lands at whichever of // the several links they happen to click. // // The revocation uses the same conditions Revoke does — unaccepted and // unrevoked, expiry not consulted, since an expired row is already // dead and revoking it costs nothing — so a spent invitation is never // rewritten and the record of what was accepted stays true. func (rs *Roster) Invite(ctx context.Context, actor *Member, email string, role Role) (*Invitation, string, error) { if actor == nil { return nil, "", forbidden("no actor") } email = normalizeEmail(email) if email == "" { return nil, "", fmt.Errorf("%w: Invite needs a non-empty email", ErrInvalidEmail) } token, err := newToken() if err != nil { return nil, "", fmt.Errorf("idear: minting invitation token: %w", err) } now := rs.now() inv := &Invitation{ Email: email, Role: role, TokenHash: hashToken(token), // The .UTC() is redundant today — rs.now() already returns // UTC and Add preserves the location — and it stays anyway. // Expiry is decided by a TEXT comparison over // time.Time.String(), and String() renders the zone: a value // in Europe/Dublin becomes "... +0100 IST", which sorts // against "... +0000 UTC" by the offset characters and gives // an answer unrelated to which instant is later. That is a // worse failure than the monotonic-reading trap in rs.now(), // because it is silent and seasonal. One call pins it at the // only site whose value is ever compared in SQL. ExpiresAt: now.Add(rs.cfg.InviteTTL).UTC(), } err = rs.tx(ctx, func(tx *gorm.DB) error { cur, err := loadMember(tx, actor.ID) if err != nil { return err } if err := mayManage(cur); err != nil { return err } if err := checkInviteRole(cur, role); err != nil { return err } // Supersede, in the SAME transaction as the create: a reader // must never see two live invitations for one address, and a // revocation that committed without its replacement would // leave the invitee holding nothing. if err := tx.Model(&Invitation{}). Where("email = ? AND accepted_at IS NULL AND revoked_at IS NULL", email). Update("revoked_at", now).Error; err != nil { return err } inv.InvitedBy = cur.ID return tx.Create(inv).Error }) if err != nil { return nil, "", err } return inv, token, nil } // Revoke kills an outstanding invitation. It refuses with // ErrNoInvitation when there is nothing to kill — no such id, or one // already accepted or already revoked — which is the same answer // Accept gives, so neither call distinguishes the cases for a caller // who should not be told them apart. // // The update is conditional and rows-affected-checked, so Revoke // racing Accept resolves one way or the other and never both: whichever // transaction commits first leaves the other's WHERE clause matching // nothing. func (rs *Roster) Revoke(ctx context.Context, actor *Member, id int64) error { if actor == nil { return forbidden("no actor") } now := rs.now() return rs.tx(ctx, func(tx *gorm.DB) error { cur, err := loadMember(tx, actor.ID) if err != nil { return err } if err := mayManage(cur); err != nil { return err } res := tx.Model(&Invitation{}). Where("id = ? AND accepted_at IS NULL AND revoked_at IS NULL", id). Update("revoked_at", now) if res.Error != nil { return res.Error } if res.RowsAffected != 1 { return ErrNoInvitation } return nil }) } // Accept redeems an invitation token and writes the Member it buys. // // The invitation is consumed by COMPARE-AND-SWAP, not by lookup: one // conditional UPDATE that requires the row to be unaccepted, unrevoked // and unexpired, with rows-affected checked, in the same transaction // as the Member insert. A lookup followed by a write is not the same // thing — between the two, Revoke can commit, and the invitation is // admitted after it was withdrawn. // // Single use cannot be delegated to the app's own unique-email index // either: idear can neither see that index nor enforce it, and the // app's user row is written by code idear does not control. The CAS is // the invariant, and it is the only one. // // The role is taken from the stored invitation, never from a caller, // and RoleOwner is refused even here — an owner-role invitation should // be impossible to mint, and a row that carries one is corruption, not // permission. // // A subject that already has a Member row — including a DEACTIVATED // one, because removal is never a delete — collides with the unique // index and rolls the whole transaction back, invitation included. A // returning member is readmitted by Reactivate, not by a fresh // invitation; that is exactly why Reactivate exists (design spec §4). func (rs *Roster) Accept(ctx context.Context, token, subject, name string) (*Member, error) { subject = normalizeSubject(subject) if subject == "" { return nil, fmt.Errorf("%w: Accept needs a non-empty subject", ErrInvalidSubject) } if token == "" { return nil, ErrNoInvitation } hash := hashToken(token) now := rs.now() var m *Member err := rs.tx(ctx, func(tx *gorm.DB) error { res := tx.Model(&Invitation{}). Where("token_hash = ? AND accepted_at IS NULL AND revoked_at IS NULL AND expires_at > ?", hash, now). Update("accepted_at", now) if res.Error != nil { return res.Error } if res.RowsAffected != 1 { return ErrNoInvitation } // Safe only because the CAS above just claimed this row inside // this transaction: the read cannot see a competing writer. var inv Invitation if err := tx.Where("token_hash = ?", hash).Take(&inv).Error; err != nil { return err } var mErr error m, mErr = memberFromInvitation(tx, &inv, subject, name) return mErr }) if err != nil { return nil, err } return m, nil } // memberFromInvitation writes the Member a JUST-CONSUMED invitation // buys, inside the same transaction that consumed it. // // It is shared by the two paths that spend an invitation — Accept, by // token, under password; acceptByAddress, by verified address, under // keymail — so the rule they enforce about the granted role is one // piece of code that cannot drift between them. // // The role is taken from the stored invitation, never from a caller, // and RoleOwner is refused even here: an owner-role invitation should // be impossible to mint, and a row that carries one is corruption, not // permission. func memberFromInvitation(tx *gorm.DB, inv *Invitation, subject, name string) (*Member, error) { if !inv.Role.Valid() || inv.Role == RoleOwner { return nil, forbidden("invitation carries role %q, which cannot be granted", string(inv.Role)) } m := &Member{ Subject: normalizeSubject(subject), Email: inv.Email, Name: strings.TrimSpace(name), Role: inv.Role, } if err := tx.Create(m).Error; err != nil { return nil, err } return m, nil } // acceptByAddress is Accept for the keymail path, which has a VERIFIED // ADDRESS and no token: it redeems the oldest still-redeemable // invitation for email and writes the Member it buys. // // Invite revokes any live invitation for an address before writing a // new one, so "the oldest" is normally "the only one". The Order("id") // stays because this must still be deterministic against rows written // by a seed, a migration or a hand-repaired database — and because a // SELECT with no ORDER BY is a coin toss, not a rule. // // It exists rather than reusing Accept because only the plaintext // token can address a row by token_hash, and keymail never sees one — // auth's whole flow is "we mailed this address a link and it came // back". The address is the credential there, and ONLY there; the // password path must have the token as well, because password verifies // no address at all. See Roster.Authorize and Roster.Admitting. // // The consumption is a CAS with the same three conditions Accept uses, // in the same transaction as the Member write, for the same reason: a // lookup followed by a write lets Revoke commit in between and admits // an invitation after it was withdrawn. The SELECT above it only picks // a candidate — every condition is re-stated in the UPDATE and // rows-affected is checked, so the selection being stale costs a // refusal and never an admission. // // A subject that already has a Member row — a DEACTIVATED one // included, because removal is never a delete — collides with the // unique index and rolls the whole transaction back, invitation // included. Authorize never reaches here in that case (it refuses a // deactivated member first), and a returning member is readmitted by // Reactivate rather than by a fresh invitation. func (rs *Roster) acceptByAddress(ctx context.Context, email, subject, name string) (*Member, error) { email = normalizeEmail(email) subject = normalizeSubject(subject) if email == "" { return nil, ErrNoInvitation } if subject == "" { return nil, fmt.Errorf("%w: acceptByAddress needs a non-empty subject", ErrInvalidSubject) } now := rs.now() var m *Member err := rs.tx(ctx, func(tx *gorm.DB) error { var inv Invitation err := tx.Where("email = ? AND accepted_at IS NULL AND revoked_at IS NULL AND expires_at > ?", email, now). Order("id").Take(&inv).Error if errors.Is(err, gorm.ErrRecordNotFound) { return ErrNoInvitation } if err != nil { return err } res := tx.Model(&Invitation{}). Where("id = ? AND accepted_at IS NULL AND revoked_at IS NULL AND expires_at > ?", inv.ID, now). Update("accepted_at", now) if res.Error != nil { return res.Error } if res.RowsAffected != 1 { return ErrNoInvitation } m, err = memberFromInvitation(tx, &inv, subject, name) return err }) if err != nil { return nil, err } return m, nil } // addMember writes a plain Member row at role, with no invitation // behind it. It is the OPEN SIGN-UP path and nothing else: every other // way into the roster carries an invariant (Claim's zero rows, // Accept's CAS) that this deliberately has none of. // // It is unexported for that reason — an exported "just add someone" // would be a way around every one of those invariants — and it refuses // RoleOwner outright, because ownership arrives only by Claim and // moves only by Transfer. // // One INSERT, so no transaction: the unique index on Subject is the // only invariant in play and the statement either satisfies it or // fails. func (rs *Roster) addMember(ctx context.Context, subject, email, name string, role Role) (*Member, error) { subject = normalizeSubject(subject) if subject == "" { return nil, fmt.Errorf("%w: addMember needs a non-empty subject", ErrInvalidSubject) } if !role.Valid() { return nil, fmt.Errorf("%w: %q", ErrInvalidRole, string(role)) } if role == RoleOwner { return nil, forbidden("ownership arrives only by Claim and moves only by Transfer") } m := &Member{ Subject: normalizeSubject(subject), Email: normalizeEmail(email), Name: strings.TrimSpace(name), Role: role, } if err := rs.cfg.DB.WithContext(ctx).Create(m).Error; err != nil { return nil, err } return m, nil } // SetRole changes target's role. // // Refusals, in the order they are checked and for the reason each is // checked where it is: // // - the new role must be one of the three, and must not be // RoleOwner: ownership moves only by Transfer (checkInviteRole). // - actor must clear the authority floor — active, at least Admin — // so a plain Member is refused for lacking authority and learns // nothing about the target. // - the target must not BE the Owner: ErrLastOwner. Demoting the // Owner by this route would leave the instance with no Owner at // all, so this is an invariant and not a permission, and it is // checked against the row inside the transaction. // - MayActOn(actor, target) for the rest of the matrix. // // A deactivated target may have their role changed; it takes effect if // and when they are reactivated. func (rs *Roster) SetRole(ctx context.Context, actor, target *Member, role Role) error { if actor == nil || target == nil { return forbidden("no actor or no target") } if !role.Valid() { return fmt.Errorf("%w: %q", ErrInvalidRole, string(role)) } if role == RoleOwner { return forbidden("ownership moves only by Transfer, never by SetRole") } return rs.tx(ctx, func(tx *gorm.DB) error { cur, err := loadMember(tx, actor.ID) if err != nil { return err } if err := mayManage(cur); err != nil { return err } tgt, err := loadMember(tx, target.ID) if err != nil { return err } if tgt.Role == RoleOwner { return ErrLastOwner } if err := MayActOn(cur, tgt); err != nil { return err } // checkInviteRole repeats the RoleOwner refusal above, and // deliberately: the early one answers before a transaction is // opened, this one answers against the actor's CURRENT rank. // Deleting either leaves the other holding, which a mutation // run confirmed — RoleOwner is refused three ways here (the // early guard, this branch, and the strictly-below-actor rank // rule, which no actor can clear for owner) and the suite only // goes red when all three are gone. if err := checkInviteRole(cur, role); err != nil { return err } res := tx.Model(&Member{}).Where("id = ? AND role <> ?", tgt.ID, RoleOwner). Update("role", role) if res.Error != nil { return res.Error } if res.RowsAffected != 1 { return ErrLastOwner } return nil }) } // Deactivate removes target from the roster — by setting // DeactivatedAt, never by deleting the row, because a deleted row // dangles every AuthorID in the app's own tables. // // The Owner can never be deactivated (ErrLastOwner), checked against // the row inside the transaction. That check is what makes Transfer // racing a Deactivate of the same target safe from this side: if the // transfer commits first, this transaction re-reads a target who is // now the Owner and refuses, instead of deactivating the Owner it just // became. // // Deactivating an already-deactivated member is a no-op, not an error. func (rs *Roster) Deactivate(ctx context.Context, actor, target *Member) error { if actor == nil || target == nil { return forbidden("no actor or no target") } now := rs.now() return rs.tx(ctx, func(tx *gorm.DB) error { cur, err := loadMember(tx, actor.ID) if err != nil { return err } if err := mayManage(cur); err != nil { return err } tgt, err := loadMember(tx, target.ID) if err != nil { return err } if tgt.Role == RoleOwner { return ErrLastOwner } if err := MayActOn(cur, tgt); err != nil { return err } if !tgt.Active() { return nil } res := tx.Model(&Member{}). Where("id = ? AND deactivated_at IS NULL AND role <> ?", tgt.ID, RoleOwner). Update("deactivated_at", now) if res.Error != nil { return res.Error } if res.RowsAffected != 1 { return ErrLastOwner } return nil }) } // Reactivate readmits a deactivated member at the role they still // carry. // // It is a first-class operation and not a convenience: Subject is // unique and removal is deactivation, so without Reactivate a removed // person can never be readmitted by ANY path — keymail's Authorize // sees the inactive row and refuses, password re-signup hits the app's // duplicate-email check, and a fresh invitation's Member insert // collides with the dead row (design spec §4). // // Reactivating an already-active member is a no-op, not an error. func (rs *Roster) Reactivate(ctx context.Context, actor, target *Member) error { if actor == nil || target == nil { return forbidden("no actor or no target") } return rs.tx(ctx, func(tx *gorm.DB) error { cur, err := loadMember(tx, actor.ID) if err != nil { return err } if err := mayManage(cur); err != nil { return err } tgt, err := loadMember(tx, target.ID) if err != nil { return err } if err := MayActOn(cur, tgt); err != nil { return err } if tgt.Active() { return nil } res := tx.Model(&Member{}). Where("id = ? AND deactivated_at IS NOT NULL", tgt.ID). Update("deactivated_at", nil) if res.Error != nil { return res.Error } if res.RowsAffected != 1 { return ErrNotFound } return nil }) } // Transfer hands ownership of the instance from owner to to, in one // transaction: demote the outgoing Owner to Admin, promote the // incoming one to Owner. Exactly one Owner exists at every commit // boundary, including this one — there is no instant, even inside the // transaction, at which the instance has two Owners or none. // // Both rows are RE-READ inside the transaction, and two things // confirmed about them: // // - the actor is still the Owner. Six concurrent transfers therefore // resolve to one: the first commits, and every other transaction // re-reads an actor who is now an Admin. Round 1 of the bake-off // found exactly this bug in a hand-rolled version, and found it // only because someone ran it as an actual race. // - the target is still ACTIVE. Without this, a transfer racing a // Deactivate of the same target produces a DEACTIVATED Owner: an // instance with nobody able to administer it and nobody able to be // promoted, because MayActOn lets no rank act on an Owner. It is // unrecoverable through idear's own API, which is what makes it // worth a re-read rather than a comment. // // Both updates are conditional and rows-affected-checked as well, so // the invariant is stated at the statement and not only in the // preceding reads. func (rs *Roster) Transfer(ctx context.Context, owner, to *Member) error { if owner == nil || to == nil { return forbidden("no owner or no target") } if owner.ID == to.ID { return forbidden("ownership cannot be transferred to its current holder") } return rs.tx(ctx, func(tx *gorm.DB) error { cur, err := loadMember(tx, owner.ID) if err != nil { return err } if !cur.Active() || cur.Role != RoleOwner { return forbidden("only the current owner may transfer ownership") } tgt, err := loadMember(tx, to.ID) if err != nil { return err } if !tgt.Active() { return forbidden("ownership cannot be transferred to a deactivated member") } res := tx.Model(&Member{}).Where("id = ? AND role = ?", cur.ID, RoleOwner). Update("role", RoleAdmin) if res.Error != nil { return res.Error } if res.RowsAffected != 1 { return forbidden("ownership moved before this transfer could complete") } res = tx.Model(&Member{}).Where("id = ? AND deactivated_at IS NULL", tgt.ID). Update("role", RoleOwner) if res.Error != nil { return res.Error } if res.RowsAffected != 1 { return forbidden("the target stopped being an active member before this transfer could complete") } return nil }) } // BySubject resolves a session Subject to its member row, active or // not. It deliberately does NOT filter on Active: the middleware has // to be able to tell a deactivated member from a stranger in order to // log the difference, even though it answers both with the app's 404. // Callers decide with Member.Active. // // The subject is canonicalised on the way in (normalizeSubject), which // is the READ half of a pair: every Subject write is canonicalised the // same way. Under keymail the Subject is the address the visitor // typed, so without this a member who typed a capital would resolve to // nothing and 404 forever. See normalizeSubject. func (rs *Roster) BySubject(ctx context.Context, subject string) (*Member, error) { subject = normalizeSubject(subject) if subject == "" { // An empty subject is a request with no session, not a // wildcard. Answering it from the database would match // whichever row happens to have an empty subject. return nil, ErrNotFound } var m Member err := rs.cfg.DB.WithContext(ctx).Where("subject = ?", subject).Take(&m).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrNotFound } if err != nil { return nil, err } return &m, nil } // ByID resolves a member id, active or not. See BySubject. func (rs *Roster) ByID(ctx context.Context, id int64) (*Member, error) { var m Member err := rs.cfg.DB.WithContext(ctx).Where("id = ?", id).Take(&m).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrNotFound } if err != nil { return nil, err } return &m, nil } // Members lists the whole roster, deactivated members included — the // members page shows them so they can be restored, and a list that // hid them would make Reactivate unreachable from the UI. // // The order is Owner, then Admins, then Members, then by id. It is // spelled out as a CASE rather than ORDER BY role because the column // is text: alphabetical order would put Admin above Owner. func (rs *Roster) Members(ctx context.Context) ([]Member, error) { var out []Member err := rs.cfg.DB.WithContext(ctx). Order("CASE role WHEN 'owner' THEN 0 WHEN 'admin' THEN 1 ELSE 2 END"). Order("id"). Find(&out).Error if err != nil { return nil, err } return out, nil } // PendingInvitations lists the invitations that can still be redeemed // right now: unaccepted, unrevoked and unexpired. Expired and spent // rows stay in the table as a record; they are simply not offered. func (rs *Roster) PendingInvitations(ctx context.Context) ([]Invitation, error) { var out []Invitation err := rs.cfg.DB.WithContext(ctx). Where("accepted_at IS NULL AND revoked_at IS NULL AND expires_at > ?", rs.now()). Order("id"). Find(&out).Error if err != nil { return nil, err } return out, nil } // pendingInvitation resolves a plaintext token to its invitation, and // refuses with ErrNoInvitation for every unusable case alike: no such // token, already accepted, revoked, or expired. One answer, not four — // the holder of a token is not entitled to learn WHICH. // // It is ADVISORY and it is unexported because of that. It is answered // from the read pool, so it can lag a Revoke by a WAL snapshot, and it // is a lookup rather than a consumption. Nothing may admit on its // answer alone: Accept's CAS re-checks all three conditions inside the // transaction that spends the invitation, and that is where the answer // binds. Admission uses this only to decide the EMAIL MATCH, which is // the one fact about an invitation that never changes after it is // written. func (rs *Roster) pendingInvitation(ctx context.Context, token string) (*Invitation, error) { if token == "" { return nil, ErrNoInvitation } var inv Invitation err := rs.cfg.DB.WithContext(ctx).Where("token_hash = ?", hashToken(token)).Take(&inv).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrNoInvitation } if err != nil { return nil, err } if !inv.Pending(rs.now()) { return nil, ErrNoInvitation } return &inv, nil }