diff --git a/server/src/model/teams/teamAccess.db.js b/server/src/model/teams/teamAccess.db.js new file mode 100644 index 0000000..e21fe9a --- /dev/null +++ b/server/src/model/teams/teamAccess.db.js @@ -0,0 +1,94 @@ +// SQL for the two tables the access resolver reads: forum grants (path 3) and +// staff leadership overrides (§2.5.1). +// +// Kept separate from teams.db.js on purpose. The four authority paths are four +// tables answering four questions, and the single most important structural rule +// in TEAMS.md is that no resolver reads another path's table — a file boundary is +// a cheap way to make crossing one visible in a diff. + +const { query } = require('../../utils/db') + +// ── team_forum_grants (path 3) ───────────────────────────────────────────── + +const GRANT_COLUMNS = ` + id, team_id, user_id, username, granted_by, granted_username, granted_at, reason, + revoked_by, revoked_username, revoked_at, revoke_reason` + +/** The caller's ACTIVE grant on a team, or undefined. At most one, by the unique key. */ +async function activeGrant(teamId, userId) { + const rows = await query( + `SELECT ${GRANT_COLUMNS} FROM team_forum_grants + WHERE team_id = ? AND user_id = ? AND revoked_at IS NULL`, + [teamId, userId], + ) + return rows[0] +} + +/** The whole ledger for a team, revoked rows included — the admin grant view. */ +async function grantLedger(teamId) { + return query( + `SELECT ${GRANT_COLUMNS} FROM team_forum_grants WHERE team_id = ? ORDER BY granted_at DESC, id DESC`, + [teamId], + ) +} + +/** Active grants only, for the "Forum guests" list and the per-team cap. */ +async function activeGrants(teamId) { + return query( + `SELECT ${GRANT_COLUMNS} FROM team_forum_grants WHERE team_id = ? AND revoked_at IS NULL + ORDER BY granted_at`, + [teamId], + ) +} + +// ── team_leader_overrides (§2.5.1) ───────────────────────────────────────── + +const OVERRIDE_COLUMNS = 'team_id, member_key, effect, actor_user_id, actor_username, reason, created_at' + +async function overridesForTeam(teamId) { + return query(`SELECT ${OVERRIDE_COLUMNS} FROM team_leader_overrides WHERE team_id = ? ORDER BY member_key`, + [teamId]) +} + +async function overrideFor(teamId, memberKey) { + const rows = await query( + `SELECT ${OVERRIDE_COLUMNS} FROM team_leader_overrides WHERE team_id = ? AND member_key = ?`, + [teamId, memberKey], + ) + return rows[0] +} + +/** + * Set or replace one override. + * + * The projection is never touched by this — `team_members.is_leader` keeps saying + * what the game says and this keeps saying what staff decided, which is the entire + * point (§2.5.1). An override applied INTO the projection would be clobbered by + * the next sync, fifteen minutes later. + */ +async function setOverride({ teamId, memberKey, effect, actorUserId, actorUsername, reason }) { + await query( + `INSERT INTO team_leader_overrides (team_id, member_key, effect, actor_user_id, actor_username, reason) + VALUES (?, ?, ?, ?, ?, ?) + ON DUPLICATE KEY UPDATE + effect = VALUES(effect), actor_user_id = VALUES(actor_user_id), + actor_username = VALUES(actor_username), reason = VALUES(reason), created_at = NOW()`, + [teamId, memberKey, effect, actorUserId, actorUsername, reason], + ) +} + +async function clearOverride(teamId, memberKey) { + const res = await query('DELETE FROM team_leader_overrides WHERE team_id = ? AND member_key = ?', + [teamId, memberKey]) + return res.affectedRows > 0 +} + +module.exports = { + activeGrant, + grantLedger, + activeGrants, + overridesForTeam, + overrideFor, + setOverride, + clearOverride, +} diff --git a/server/src/model/teams/teamAccess.model.js b/server/src/model/teams/teamAccess.model.js new file mode 100644 index 0000000..83cecfe --- /dev/null +++ b/server/src/model/teams/teamAccess.model.js @@ -0,0 +1,131 @@ +// ── The four authority paths ─────────────────────────────────────────────── +// +// The single most important structural rule in TEAMS.md (§2.5): these are four +// tables answering four questions, and **no resolver reads another path's table.** +// +// 1. Is this account a member? module team_members +// 2. Does this account lead the Team? module team_members.is_leader, +// plus a staff override +// 3. May it use the Team forum? CORE team_forum_grants OR path 1 +// 4. May it get external-platform CORE, nothing of its own +// access? derived +// +// The temptation this file exists to resist is collapsing 1 and 3 into one +// boolean. They answer different questions about different populations: a forum +// grant may name any Runic Gateway account, including one with no game identity +// at all — that is the point of it, since letting an unlinked guildmate into the +// forum must not require a staff ticket. Treating "has forum access" as "is a +// member" would put that person on the roster, in the member count, and into the +// external-platform grant, which is where it stops being a modelling preference +// and becomes an impersonation risk (path 4 below). +// +// Non-contamination is the invariant: a manual grant never writes the membership +// projection, in either direction, ever. Both facts coexist and neither migrates +// into the other. + +const accessDb = require('./teamAccess.db') +const teamsDb = require('./teams.db') +const identities = require('../userIdentities/userIdentities.model') + +/** + * Path 3 — forum access. Two reads, OR'd, and nothing else. + * + * `viaGrant` is reported even when membership also holds, deliberately: both + * facts are true, the UI presents membership as the current reason, and the grant + * survives as audit history. Collapsing them into one boolean is what loses the + * record of who let this person in and why. + */ +async function forumAccess(teamId, userId) { + if (!userId) return { allowed: false, viaMembership: false, viaGrant: false, isLeader: false } + + const [grant, member] = await Promise.all([ + accessDb.activeGrant(teamId, userId), // path 3's own table + teamsDb.activeByUser(teamId, userId), // path 1 + ]) + + return { + allowed: Boolean(grant) || Boolean(member), + viaMembership: Boolean(member), + viaGrant: Boolean(grant), + isLeader: member ? await isLeader(teamId, member) : false, + } +} + +/** + * Path 2 — leadership, with the staff override applied ON TOP of the synced value + * at read time (§2.5.1). + * + * Applied at read rather than written into the projection because the sync owns + * that column and rewrites it every interval. An override that lived in + * `team_members` would be undone fifteen minutes after staff set it, which is the + * whole reason this is a separate table read here. + */ +async function isLeader(teamId, member) { + if (!member) return false + const override = await accessDb.overrideFor(teamId, member.member_key) + if (override) return override.effect === 'grant' + return Boolean(member.is_leader) +} + +/** Leadership for a caller identified by user id rather than by a member row. */ +async function isLeaderByUser(teamId, userId) { + if (!userId) return false + const member = await teamsDb.activeByUser(teamId, userId) + return isLeader(teamId, member) +} + +/** + * Path 4 — external-platform eligibility. Computed, no table of its own, and + * deliberately blind to path 3. + * + * The reason, stated so nobody "fixes" it later: an integration cannot verify + * that an unlinked, forum-granted account corresponds to a real game member, so + * it must not hand that account a privilege on a platform where impersonation has + * consequences. A forum is a room on the operator's own site with a known + * moderator; a Discord role is an identity claim in someone else's space. + */ +async function externalEligible(teamId, userId, platform) { + if (!userId || !platform) return false + const member = await teamsDb.activeByUser(teamId, userId) // path 1 ONLY + if (!member || member.user_id == null) return false // must be a LINKED game member + const linked = await identities.listForUser(userId) + return linked.some((i) => i.provider === platform) +} + +/** + * A team's roster with overrides folded in, for the admin view and the Team page. + * + * The rows returned carry `is_leader` as RESOLVED — synced value plus override — + * and `is_leader_synced` as what the game actually said, so the admin surface can + * show that a decision was made rather than silently presenting it as fact. + */ +async function rosterWithOverrides(teamId, { includeDeparted = false } = {}) { + const [members, overrides] = await Promise.all([ + teamsDb.membersByTeam(teamId, { includeDeparted }), + accessDb.overridesForTeam(teamId), + ]) + const byKey = new Map(overrides.map((o) => [o.member_key, o])) + return members.map((m) => { + const override = byKey.get(m.member_key) + return { + ...m, + is_leader_synced: Boolean(m.is_leader), + is_leader: override ? override.effect === 'grant' : Boolean(m.is_leader), + leader_override: override + ? { effect: override.effect, reason: override.reason, by: override.actor_username, at: override.created_at } + : null, + } + }) +} + +module.exports = { + forumAccess, + isLeader, + isLeaderByUser, + externalEligible, + rosterWithOverrides, + setLeaderOverride: accessDb.setOverride, + clearLeaderOverride: accessDb.clearOverride, + grantLedger: accessDb.grantLedger, + activeGrants: accessDb.activeGrants, +} diff --git a/server/test/teamAccess.test.js b/server/test/teamAccess.test.js new file mode 100644 index 0000000..3d70ce2 --- /dev/null +++ b/server/test/teamAccess.test.js @@ -0,0 +1,209 @@ +// The four authority paths, and the rule that they stay four +// (docs/website/TEAMS.md §2.5). +// +// Two of these tests are named for invariants rather than for behaviour, because +// what they protect is a structural property that a perfectly reasonable-looking +// refactor destroys: "has forum access" is never read as "is a member", and a +// grant never writes the membership projection. Both are one `||` away from being +// wrong, and neither failure is visible on any screen — the first shows up as a +// stranger on a public roster, the second as a Discord role handed to an account +// nobody can tie to a real player. +const { test, beforeEach, afterEach } = require('node:test') +const assert = require('node:assert/strict') + +const accessDb = require('../src/model/teams/teamAccess.db') +const teamsDb = require('../src/model/teams/teams.db') +const identities = require('../src/model/userIdentities/userIdentities.model') +const access = require('../src/model/teams/teamAccess.model') + +const saved = [] +function patch(mod, name, fn) { + saved.push([mod, name, mod[name]]) + mod[name] = fn +} + +// Every read the four paths can make, stubbed to "nothing there". Each test then +// states only the fact it is about, which is what makes a crossed path obvious: +// a resolver reading a table it should not would come back empty and the +// assertion would say so. +function stubAll() { + patch(accessDb, 'activeGrant', async () => undefined) + patch(accessDb, 'overrideFor', async () => undefined) + patch(accessDb, 'overridesForTeam', async () => []) + patch(teamsDb, 'activeByUser', async () => undefined) + patch(teamsDb, 'membersByTeam', async () => []) + patch(identities, 'listForUser', async () => []) +} + +const memberRow = (extra = {}) => ({ + team_id: 1, member_key: '0x1', user_id: 7, is_leader: 0, status: 'active', display_name: 'Aldric', ...extra, +}) +const grantRow = (extra = {}) => ({ id: 1, team_id: 1, user_id: 7, granted_by: 2, revoked_at: null, ...extra }) + +beforeEach(stubAll) +afterEach(() => { + while (saved.length) { + const [mod, name, fn] = saved.pop() + mod[name] = fn + } +}) + +// ── Path 3: forum access is membership OR a grant ────────────────────────── + +test('a member has forum access via membership', async () => { + patch(teamsDb, 'activeByUser', async () => memberRow()) + const result = await access.forumAccess(1, 7) + assert.deepEqual(result, { allowed: true, viaMembership: true, viaGrant: false, isLeader: false }) +}) + +test('a granted non-member has forum access via the grant', async () => { + patch(accessDb, 'activeGrant', async () => grantRow()) + const result = await access.forumAccess(1, 7) + assert.deepEqual(result, { allowed: true, viaMembership: false, viaGrant: true, isLeader: false }) +}) + +test('both reasons are reported when both hold', async () => { + // Not collapsed into one boolean: both facts are true, membership is what the + // UI shows as the current reason, and the grant stays as the record of who let + // this person in before they were a member. + patch(accessDb, 'activeGrant', async () => grantRow()) + patch(teamsDb, 'activeByUser', async () => memberRow()) + const result = await access.forumAccess(1, 7) + assert.equal(result.viaMembership, true) + assert.equal(result.viaGrant, true) +}) + +test('a revoked grant and no membership is no access', async () => { + // activeGrant returns nothing for a revoked row — the resolver never sees one. + const result = await access.forumAccess(1, 7) + assert.equal(result.allowed, false) +}) + +test('an anonymous caller is refused without touching a table', async () => { + let reads = 0 + patch(accessDb, 'activeGrant', async () => { reads += 1 }) + patch(teamsDb, 'activeByUser', async () => { reads += 1 }) + const result = await access.forumAccess(1, null) + assert.equal(result.allowed, false) + assert.equal(reads, 0) +}) + +// ── Invariant 3: non-contamination ───────────────────────────────────────── + +test('INVARIANT — a grant never writes the membership projection', async () => { + // The grant path reads its own table and nothing else. Asserted by making every + // membership WRITE explode: if resolving a grant ever wrote a member row, this + // is where it would surface. + for (const name of ['upsertMember', 'markDeparted', 'setLeaders', 'setMemberLeader']) { + patch(teamsDb, name, async () => { throw new Error(`forumAccess wrote team_members via ${name}`) }) + } + patch(accessDb, 'activeGrant', async () => grantRow()) + const result = await access.forumAccess(1, 7) + assert.equal(result.allowed, true) + assert.equal(result.viaMembership, false, 'a grant is not a membership, in either direction') +}) + +test('INVARIANT — a granted, unlinked user is not on the roster', async () => { + // The roster is path 1's table alone. A granted user with no membership row + // appears nowhere in it, which is what keeps them out of every membership count. + patch(accessDb, 'activeGrant', async () => grantRow({ user_id: 99 })) + patch(teamsDb, 'membersByTeam', async () => [memberRow()]) + + const roster = await access.rosterWithOverrides(1) + assert.equal(roster.length, 1) + assert.equal(roster.every((m) => m.user_id !== 99), true, 'a forum guest is not a member') +}) + +// ── Path 4: external access is blind to path 3 ───────────────────────────── + +test('INVARIANT — a forum grant does not make an account externally eligible', async () => { + // The named test from §2.5. An integration cannot verify that an unlinked, + // forum-granted account corresponds to a real game member, so it must not hand + // that account a privilege on a platform where impersonation has consequences. + patch(accessDb, 'activeGrant', async () => grantRow()) + patch(identities, 'listForUser', async () => [{ provider: 'discord', subject: 'd1' }]) + + assert.equal(await access.externalEligible(1, 7, 'discord'), false) +}) + +test('a linked member with a linked Discord identity is eligible', async () => { + patch(teamsDb, 'activeByUser', async () => memberRow({ user_id: 7 })) + patch(identities, 'listForUser', async () => [{ provider: 'discord', subject: 'd1' }]) + assert.equal(await access.externalEligible(1, 7, 'discord'), true) +}) + +test('a member with no Discord identity is not eligible — hop 3 of the chain', async () => { + patch(teamsDb, 'activeByUser', async () => memberRow()) + patch(identities, 'listForUser', async () => [{ provider: 'google', subject: 'g1' }]) + assert.equal(await access.externalEligible(1, 7, 'discord'), false) +}) + +test('eligibility is per platform, not "linked to anything"', async () => { + patch(teamsDb, 'activeByUser', async () => memberRow()) + patch(identities, 'listForUser', async () => [{ provider: 'discord', subject: 'd1' }]) + assert.equal(await access.externalEligible(1, 7, 'matrix'), false) +}) + +test('a non-member is never eligible', async () => { + patch(identities, 'listForUser', async () => [{ provider: 'discord', subject: 'd1' }]) + assert.equal(await access.externalEligible(1, 7, 'discord'), false) +}) + +// ── Path 2: leadership, and the staff override on top ────────────────────── + +test('leadership follows the synced value when no override exists', async () => { + patch(teamsDb, 'activeByUser', async () => memberRow({ is_leader: 1 })) + assert.equal(await access.isLeaderByUser(1, 7), true) +}) + +test('a deny override outranks a synced leader', async () => { + patch(teamsDb, 'activeByUser', async () => memberRow({ is_leader: 1 })) + patch(accessDb, 'overrideFor', async () => ({ member_key: '0x1', effect: 'deny' })) + assert.equal(await access.isLeaderByUser(1, 7), false) +}) + +test('a grant override promotes someone the game does not call a leader', async () => { + patch(teamsDb, 'activeByUser', async () => memberRow({ is_leader: 0 })) + patch(accessDb, 'overrideFor', async () => ({ member_key: '0x1', effect: 'grant' })) + assert.equal(await access.isLeaderByUser(1, 7), true) +}) + +test('an override survives a resync, because it is never written into the projection', async () => { + // The projection keeps saying what the game says; the override keeps saying what + // staff decided. Applied at READ time, so a sync fifteen minutes later cannot + // undo it — which is the entire point of §2.5.1. + patch(accessDb, 'overridesForTeam', async () => [ + { member_key: '0x1', effect: 'deny', reason: 'harassment', actor_username: 'mod1', created_at: 'then' }, + ]) + patch(teamsDb, 'membersByTeam', async () => [memberRow({ is_leader: 1 })]) + + const roster = await access.rosterWithOverrides(1) + assert.equal(roster[0].is_leader, false, 'the resolved answer is the override') + assert.equal(roster[0].is_leader_synced, true, 'what the game says is still visible') + assert.equal(roster[0].leader_override.reason, 'harassment') + assert.equal(roster[0].leader_override.by, 'mod1') +}) + +test('a member with no override carries no override field', async () => { + patch(teamsDb, 'membersByTeam', async () => [memberRow({ is_leader: 1 })]) + const roster = await access.rosterWithOverrides(1) + assert.equal(roster[0].leader_override, null) + assert.equal(roster[0].is_leader, true) +}) + +test('leadership resolves through forumAccess too, override included', async () => { + patch(teamsDb, 'activeByUser', async () => memberRow({ is_leader: 0 })) + patch(accessDb, 'overrideFor', async () => ({ member_key: '0x1', effect: 'grant' })) + const result = await access.forumAccess(1, 7) + assert.equal(result.isLeader, true) +}) + +test('a granted non-member is never a leader', async () => { + // isLeader is path 2, which is a property of a MEMBER row. Someone with only a + // forum grant has no member row, so there is nothing to promote. + patch(accessDb, 'activeGrant', async () => grantRow()) + patch(accessDb, 'overrideFor', async () => ({ member_key: '0x1', effect: 'grant' })) + const result = await access.forumAccess(1, 7) + assert.equal(result.allowed, true) + assert.equal(result.isLeader, false) +})