mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[api] audit logging
This commit is contained in:
@@ -21,6 +21,7 @@ import {
|
||||
subRoomDataBlob,
|
||||
updateAccount,
|
||||
verifyPassword,
|
||||
writeAuditLog,
|
||||
} from '@repo/domain'
|
||||
import {
|
||||
intVar,
|
||||
@@ -1121,6 +1122,58 @@ const app = new Hono<App>()
|
||||
// grant_type=refresh_token). A refresh grant thus rotates its token.
|
||||
const refreshToken = await issueRefreshToken(c.env.DB, Number(accountId))
|
||||
|
||||
// A STAFF sign-in is an audit event in its own right. What these accounts then do is
|
||||
// recorded against them on the `audit_log` table (`notify` files every `/internal/*`
|
||||
// call there); this is the row saying the session those actions came from began — and
|
||||
// from what device, build and IP, which is the first thing anyone asks when an action
|
||||
// further down the log looks wrong. The flags themselves are the trigger, so an account
|
||||
// granted the role between logins starts being recorded the next time it signs in.
|
||||
//
|
||||
// A real sign-in only: `refresh_token` renews the SAME session every TOKEN_TTL_SECONDS,
|
||||
// and recording those would bury the logins under hourly repeats that say nothing new
|
||||
// about who came in or from where.
|
||||
//
|
||||
// Written after the token is minted, so the row means a login that actually happened,
|
||||
// and caught rather than awaited into the response: a failed audit insert must not turn
|
||||
// a staffer's successful sign-in into a 500. It leaves a loud line in the worker log
|
||||
// instead — the one thing a gap in an audit trail should never do quietly.
|
||||
const staffRoles =
|
||||
roleAccount && grantType !== 'refresh_token'
|
||||
? (['developer', 'moderator'] as const).filter((role) => holdsRole(roleAccount, role))
|
||||
: []
|
||||
if (staffRoles.length > 0) {
|
||||
try {
|
||||
await writeAuditLog(c.env.DB, {
|
||||
playerId: Number(accountId),
|
||||
action: 'staff_login',
|
||||
// The IP leads: it is the first thing anyone asks of a staff sign-in, and the one
|
||||
// field here that says WHERE the session came from rather than what it could do.
|
||||
// Empty outside the Cloudflare edge — recorded as the empty string it is rather
|
||||
// than omitted, so a row can't be read as "we didn't record one".
|
||||
//
|
||||
// Then: which powers the session carries, HOW they signed in, and the same
|
||||
// unverified-but-recorded device/build context the account row keeps — kept here
|
||||
// too so the row stands on its own months later, after the account's
|
||||
// `lastLoginIp` and device have been overwritten by every login since.
|
||||
data: {
|
||||
ip: clientIp,
|
||||
roles: staffRoles,
|
||||
grantType,
|
||||
username: roleAccount?.username ?? null,
|
||||
deviceId,
|
||||
deviceClass,
|
||||
version: version ?? null,
|
||||
},
|
||||
})
|
||||
} catch (err) {
|
||||
logger.error('could not write a staff_login audit log row', {
|
||||
accountId,
|
||||
grantType,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({
|
||||
access_token: accessToken,
|
||||
expires_in: TOKEN_TTL_SECONDS,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { beforeAll, describe, expect, test } from 'vitest'
|
||||
import '../../auth.app'
|
||||
|
||||
import {
|
||||
AUDIT_LOG_SCHEMA_DDL,
|
||||
GAME_VERSION,
|
||||
getAccountsByDeviceId,
|
||||
hashPassword,
|
||||
@@ -70,6 +71,9 @@ beforeAll(async () => {
|
||||
for (const stmt of PLATFORM_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
// Presence table (owned by the rooms worker) — signup seeds the Orientation row.
|
||||
for (const stmt of PRESENCE_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
// The audit trail a staff sign-in is recorded on. Owned by the `api` worker, whose
|
||||
// migrations don't run here, so build it from the mirrored DDL.
|
||||
for (const stmt of AUDIT_LOG_SCHEMA_DDL) await env.DB.prepare(stmt).run()
|
||||
|
||||
// Seed the accounts the credential-login tests use, each with LOGIN_PASSWORD set.
|
||||
const hash = await hashPassword(LOGIN_PASSWORD)
|
||||
@@ -1780,3 +1784,120 @@ describe('ban evasion at the token endpoint', () => {
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// A staff sign-in is recorded on the `audit_log` table — the same table `notify` files
|
||||
// every `/internal/*` call on. What those accounts then do is attributed to them; this is
|
||||
// the row that says the session it came from began, and from what device, build and IP.
|
||||
describe('staff login audit', () => {
|
||||
interface AuditRow {
|
||||
player_id: number
|
||||
action: string
|
||||
data: string | null
|
||||
date: string
|
||||
}
|
||||
|
||||
const staffLogins = async (accountId: number): Promise<AuditRow[]> => {
|
||||
const { results } = await env.DB.prepare(
|
||||
`SELECT player_id, action, data, date FROM audit_log
|
||||
WHERE player_id = ?1 AND action = 'staff_login' ORDER BY audit_log_id`
|
||||
)
|
||||
.bind(accountId)
|
||||
.all<AuditRow>()
|
||||
return results
|
||||
}
|
||||
|
||||
/** Seed an account with the given flags and a known password. */
|
||||
const seedAccount = async (accountId: number, data: Record<string, unknown>) =>
|
||||
env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
|
||||
.bind(
|
||||
JSON.stringify({
|
||||
accountId,
|
||||
username: `Staff${accountId}`,
|
||||
passwordHash: await hashPassword(LOGIN_PASSWORD),
|
||||
...data,
|
||||
})
|
||||
)
|
||||
.run()
|
||||
|
||||
test('a login by an isModerator account is recorded, with the context of the sign-in', async () => {
|
||||
await seedAccount(9601, { username: 'ModPlayer', isModerator: true })
|
||||
const res = await postToken(
|
||||
`grant_type=password&account_id=9601&password=${LOGIN_PASSWORD}` +
|
||||
'&device_id=dev-mod&device_class=2&ver=20230414',
|
||||
'203.0.113.9'
|
||||
)
|
||||
expect(res.status).toBe(200)
|
||||
|
||||
const rows = await staffLogins(9601)
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(Number.isNaN(Date.parse(rows[0].date))).toBe(false)
|
||||
const data = JSON.parse(rows[0].data!) as Record<string, unknown>
|
||||
expect(data).toMatchObject({
|
||||
ip: '203.0.113.9',
|
||||
roles: ['moderator'],
|
||||
grantType: 'password',
|
||||
username: 'ModPlayer',
|
||||
deviceId: 'dev-mod',
|
||||
deviceClass: 2,
|
||||
version: '20230414',
|
||||
})
|
||||
// The IP LEADS the payload: it is the first thing anyone asks of a staff sign-in, and
|
||||
// the reader of a raw row should not have to hunt for it.
|
||||
expect(Object.keys(data)[0]).toBe('ip')
|
||||
})
|
||||
|
||||
// Both flags on one account record both — the row says which powers the session carries,
|
||||
// not merely that it carries some.
|
||||
test('an account holding both flags records both roles', async () => {
|
||||
await seedAccount(9602, { isDeveloper: true, isModerator: true })
|
||||
expect((await postToken(`account_id=9602&password=${LOGIN_PASSWORD}`)).status).toBe(200)
|
||||
|
||||
const rows = await staffLogins(9602)
|
||||
expect(rows).toHaveLength(1)
|
||||
expect((JSON.parse(rows[0].data!) as { roles: string[] }).roles).toEqual([
|
||||
'developer',
|
||||
'moderator',
|
||||
])
|
||||
})
|
||||
|
||||
// The flags are the whole trigger. An ordinary player signing in is not an audit event,
|
||||
// and recording every login would bury the ones that are.
|
||||
test('an ordinary login records nothing', async () => {
|
||||
await seedAccount(9603, {})
|
||||
expect((await postToken(`account_id=9603&password=${LOGIN_PASSWORD}`)).status).toBe(200)
|
||||
expect(await staffLogins(9603)).toHaveLength(0)
|
||||
})
|
||||
|
||||
// A refresh renews the SAME session every TOKEN_TTL_SECONDS. Recording those would bury
|
||||
// the actual sign-ins under hourly repeats saying nothing new about who came in or from
|
||||
// where — so the login is recorded once, and the refreshes after it are not.
|
||||
test('a refresh_token grant is not a login and is not recorded', async () => {
|
||||
await seedAccount(9604, { isDeveloper: true })
|
||||
const first = await postToken(`account_id=9604&password=${LOGIN_PASSWORD}`)
|
||||
expect(first.status).toBe(200)
|
||||
expect(await staffLogins(9604)).toHaveLength(1)
|
||||
|
||||
const refreshed = await postToken(
|
||||
`grant_type=refresh_token&refresh_token=${first.json.refresh_token as string}`
|
||||
)
|
||||
expect(refreshed.status).toBe(200)
|
||||
expect(await staffLogins(9604)).toHaveLength(1)
|
||||
})
|
||||
|
||||
// The flag is read at LOGIN, exactly as `rn.plus` and the token's `role` claim are, so
|
||||
// granting the role starts the trail at their next sign-in rather than retroactively.
|
||||
test('an account granted the role starts being recorded on its next login', async () => {
|
||||
await seedAccount(9605, {})
|
||||
expect((await postToken(`account_id=9605&password=${LOGIN_PASSWORD}`)).status).toBe(200)
|
||||
expect(await staffLogins(9605)).toHaveLength(0)
|
||||
|
||||
await env.DB.prepare(
|
||||
`UPDATE account SET data = json_set(data, '$.isModerator', json('true')) WHERE account_id = ?1`
|
||||
)
|
||||
.bind(9605)
|
||||
.run()
|
||||
|
||||
expect((await postToken(`account_id=9605&password=${LOGIN_PASSWORD}`)).status).toBe(200)
|
||||
expect(await staffLogins(9605)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user