mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 14:30:13 +00:00
6365ac08fa
* fix(oauth): a revoked grant mints nothing, and one refresh token rotates once (BUG-3678) Fosite's refresh validates the presented token, then calls RotateRefreshToken, CreateAccessTokenSession and CreateRefreshTokenSession, each committing on its own. Two windows followed: - A revoke landing after validation: rotation flipped the family and the new pair was inserted active, so a revoked connection came back. The account claim and fosite's own RevokeRefreshToken (RFC 7009, refresh replay) had the same hole. - Two concurrent refreshes of one token: both validated, both rotated (rotation ignored the presented signature), and both pairs ended up minted. Now: - RotateRefreshToken spends the presented token conditionally, in one transaction with the family flips. A token already inactive fails with ErrOAuthRefreshNotActive, which the adapter returns as fosite.ErrSerializationFailure (invalid_request, "multiple concurrent requests"). - A revoked grant is recorded in oauth_revoked_grants (migration 143). RevokeOAuthGrant (connected-apps revoke, adapter RevokeRefreshToken) takes the subject's users row FOR NO KEY UPDATE first, then records the grant and flips the families. The claim records every grant of the account under the users row its first statement holds. - Issuance refuses a recorded request_id under the users FOR SHARE that requireActiveUserTx already takes (ErrOAuthGrantRevoked, mapped to fosite.ErrInactiveToken). So a racing refresh either committed before the revoke's flips, which then see its rows, or finds the record. Disable, installed-app grants and client deletion were already fenced (BUG-3349, TASK-3394/3399, the client FK). Tests: store repros for both windows, the claim and a double rotation, plus test-pg legs for concurrent refreshes and a revoke racing a refresh. HTTP tests drive fosite's real token endpoint through a before-rotation seam. Each production guard was removed in turn, and the matching tests went red. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * fix(oauth): the install barrier refuses a revoked grant; rotation holds no row across the family flips (BUG-3678, codex r1) P1: an installed app's tokens are minted through installIssuanceBarrierTx, which returned before the revoked-grant check. The check now runs there under the install row. RevokeOAuthGrant locks that row (not the bot's users row, which the install lifecycle takes after the install) for an install client's grant. P2: RotateRefreshToken held the presented refresh row in a transaction while flipping the access family, and the claim and disable flip access before refresh under the users row, so the two could deadlock on Postgres. The conditional spend is its own statement again, and the flips follow it as before. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * fix(oauth): a fosite revoke of an installed app's grant takes the app-grant path (BUG-3678, codex r2) Round 2: RevokeOAuthGrant locked the install row for an installed app's grant and then flipped refresh before access, while the account claim holds the person's row and flips access before refresh. Neither shared a lock with the other, so the two could deadlock on Postgres. Installed-app grants already have their own tombstone (the binding's revoked_at, which the issuance barrier refuses) and a lock order that the barrier, RevokeUserAppGrant, the claim and disable share through the binding rows. RevokeOAuthGrant now ends a bound grant through that same path (revokeAppGrantOnce, split out of RevokeUserAppGrant, retried on deadlock). The round-1 check in the barrier and the install-row lock in RevokeOAuthGrant are gone. oauth_revoked_grants covers only grants with no binding, under the subject's users row. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * fix(oauth): a tokenless grant's revoke still locks the person (BUG-3678, codex r3) Round 3: RevokeOAuthGrant took the subject from token rows only, so a grant whose consent was recorded but whose first tokens were not yet committed had no person to lock. The revoke could then record the grant and flip nothing while a first issuance that had already passed its check committed an active token. The lookup now falls back to the consent's oauth_connections row. A Postgres test holds the person's row the way issuance does and requires the revoke to wait on it. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * fix(oauth): the claim also ends grants with no token yet (BUG-3678, codex r4) Round 4: the claim recorded revoked grants from token rows only. A grant consented to before the claim but whose code was not yet exchanged had none, so an exchange that validated before the claim minted an active token after it. The claim now also records the request ids of the account's oauth_connections rows, read before it deletes them. Claude-Session: https://claude.ai/code/session_01FAVXA5mJ5BNhKmv95jxQkQ * fix(oauth): the revoked-grants table exists on Postgres too (BUG-3678) Migration 143 added oauth_revoked_grants to SQLite only; pgmigrations is a separate tree, so every Postgres read and write of it failed with 42P01 (CI Go (PostgreSQL) on #2121). 113 is its twin. Claude-Session: https://claude.ai/code/session_01Rj4CMyK2EqxerMihbGs3Kr
136 lines
6.0 KiB
Go
136 lines
6.0 KiB
Go
package store
|
|
|
|
import (
|
|
"go/ast"
|
|
"go/parser"
|
|
"go/token"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// TASK-3399 (lead ruling Q1): a delegated grant to an installed app writes
|
|
// no oauth_connections row, so it must not surface as an MCP connection
|
|
// anywhere. Every store function whose SQL reads the connection tables or
|
|
// treats token rows as chains is classified here. A new one fails until it
|
|
// is classified.
|
|
var task3399ReaderCensus = map[string]string{
|
|
"account_claim.go::claimAccountTx": censusUserRevoke,
|
|
"app_grants.go::ListUserAppGrants": censusAppPath,
|
|
"connected_apps.go::ListUserOAuthConnections": censusFilters,
|
|
"connected_apps.go::RevokeUserOAuthConnection": censusPerGrant,
|
|
"install_clients.go::DeleteInstallClientTx": censusAppPath,
|
|
"mcp_audit.go::MCPAuditNames": censusPerGrant,
|
|
"mcp_resume.go::CountLiveOAuthConnections": censusFilters,
|
|
"oauth.go::CountActiveOAuthAccessTokens": censusByDesign,
|
|
"oauth.go::DeleteAccessToken": censusPerGrant,
|
|
"oauth.go::DeleteOAuthClient": censusPerGrant,
|
|
"oauth.go::DeleteRefreshToken": censusPerGrant,
|
|
"oauth.go::OldestAccessTokenIssuedAtByRequestID": censusPerGrant,
|
|
"oauth.go::RevokeOAuthGrant": censusPerGrant,
|
|
"oauth.go::queryOAuthRequestRow": censusPerGrant,
|
|
"oauth_connections.go::AddConnectionWorkspace": censusNoRow,
|
|
"oauth_connections.go::AddCreatedWorkspaceIfPermitted": censusNoRow,
|
|
"oauth_connections.go::ConnectionWorkspaceCount": censusNoRow,
|
|
"oauth_connections.go::CreateOAuthConnection": censusNoRow,
|
|
"oauth_connections.go::DeleteOAuthConnection": censusNoRow,
|
|
"oauth_connections.go::GetOAuthConnection": censusNoRow,
|
|
"oauth_connections.go::GetOAuthConnectionAccess": censusNoRow,
|
|
"oauth_connections.go::HasActiveConnectionForUser": censusNoRow,
|
|
"oauth_connections.go::IsConnectionWorkspaceAllowed": censusNoRow,
|
|
"oauth_connections.go::IsWorkspaceCoveredForUser": censusNoRow,
|
|
"oauth_connections.go::LimitConnectionToCurrentWorkspaces": censusNoRow,
|
|
"oauth_connections.go::ListConnectionWorkspaceSlugs": censusNoRow,
|
|
"oauth_connections.go::RemoveConnectionWorkspace": censusNoRow,
|
|
"oauth_connections.go::RemoveConnectionWorkspaceUnlessLast": censusNoRow,
|
|
"oauth_connections.go::RenameConnection": censusNoRow,
|
|
"oauth_connections.go::SetScopeFlags": censusNoRow,
|
|
"oauth_connections.go::SetScopeFlagsGuarded": censusNoRow,
|
|
"oauth_connections.go::assertRowAffected": censusNoRow,
|
|
"oauth_connections.go::lockConnectionTx": censusNoRow,
|
|
"oauth_connections.go::snapshotCurrentWorkspacesSQL": censusNoRow,
|
|
"oauth_connections_backfill.go::BackfillOAuthConnections": censusFilters,
|
|
"oauth_connections_backfill.go::backfillOneChain": censusFilters,
|
|
"oauth_connections_backfill.go::collectGrantChainsForBackfill": censusFilters,
|
|
"oauth_connections_narrow.go::NarrowCurrentOnlyWildcardConnections": censusNoRow,
|
|
"oauth_sweep.go::SweepExpiredOAuthRows": censusByDesign,
|
|
"users.go::disableUserAndRevokeAccessTx": censusUserRevoke,
|
|
"users.go::eraseUserTx": censusUserRevoke,
|
|
}
|
|
|
|
const (
|
|
censusNoRow = "reads oauth_connections rows: an install chain has none, so it is untouched by construction"
|
|
censusPerGrant = "addressed by one signature, request id or client id: never lists a user's chains"
|
|
censusFilters = "lists or counts chains as connections: filters install clients (notInstallClientSQL)"
|
|
censusAppPath = "the app-grant path itself (bindings, the barrier, app lifecycle)"
|
|
censusByDesign = "counts or sweeps every token on purpose (an ops gauge, the expiry sweep)"
|
|
censusUserRevoke = "revokes or erases every credential of one user: delegated app codes through their bindings (delegated_user_id), tokens by subject"
|
|
)
|
|
|
|
func TestTask3399_EveryConnectionReaderIsClassified(t *testing.T) {
|
|
needles := []string{"oauth_connections", "oauth_connection_workspaces", "FROM oauth_access_tokens", "FROM oauth_refresh_tokens"}
|
|
fset := token.NewFileSet()
|
|
files, err := filepath.Glob("*.go")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
found := map[string]bool{}
|
|
for _, name := range files {
|
|
if strings.HasSuffix(name, "_test.go") {
|
|
continue
|
|
}
|
|
src, err := os.ReadFile(name)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
f, err := parser.ParseFile(fset, name, src, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, d := range f.Decls {
|
|
fn, ok := d.(*ast.FuncDecl)
|
|
if !ok || fn.Body == nil {
|
|
continue
|
|
}
|
|
hit := false
|
|
ast.Inspect(fn.Body, func(n ast.Node) bool {
|
|
lit, ok := n.(*ast.BasicLit)
|
|
if !ok || lit.Kind != token.STRING {
|
|
return true
|
|
}
|
|
v, err := strconv.Unquote(lit.Value)
|
|
if err != nil {
|
|
v = lit.Value
|
|
}
|
|
for _, nd := range needles {
|
|
if strings.Contains(v, nd) {
|
|
hit = true
|
|
}
|
|
}
|
|
return true
|
|
})
|
|
if hit {
|
|
found[name+"::"+fn.Name.Name] = true
|
|
}
|
|
}
|
|
}
|
|
var missing []string
|
|
for k := range found {
|
|
if _, ok := task3399ReaderCensus[k]; !ok {
|
|
missing = append(missing, k)
|
|
}
|
|
}
|
|
sort.Strings(missing)
|
|
for _, k := range missing {
|
|
t.Errorf("%s reads connection or token-chain tables and is not classified in task3399ReaderCensus", k)
|
|
}
|
|
for k := range task3399ReaderCensus {
|
|
if !found[k] {
|
|
t.Errorf("task3399ReaderCensus names %s, which no longer reads those tables", k)
|
|
}
|
|
}
|
|
}
|