fix(quota): make the claim gate atomic, and stop zeroing the storage cache
Two defects in the §9.3 quota path, both invisible to the hermetic suite: - Audit #4's TOCTOU was real and documented: QuotaCheck and ClaimServer were separate statements, so two concurrent claims by one user for two different ownerless servers both read count < max_servers and both won. The gate now lives inside ClaimServer, in the SAME transaction as the ownership write, under pg_advisory_xact_lock(hashtext(user_id)) — the aggregate read, the four-dimension re-check (shared with QuotaCheck via one helper so the two cannot drift), and the UPDATE are one serialized decision. The loser gets ErrQuotaExceeded, which both claim handlers map to the same 403 the sequential path gives; the server row is additionally taken FOR UPDATE so same-server races still resolve to exactly one winner. - The server PATCH path called UpdateServerResources(..., 0) for storage even though a resources patch cannot change storage. The cached columns are the ONLY input to the quota aggregate, so every resource patch silently dropped that server's storage contribution from its owner's cap. The handler now reads the current spec and passes storage through. Red-then-green: the new pgint test drives two real concurrent claims against max_servers=1 (before: both win; now: exactly one win + one gated 403, and the DB shows one owned row); the hermetic suite pins the 403 mapping and the storage-preserving cache write.
This commit is contained in:
8 files changed
+274
-50
No files matched your search
@@ -38,8 +38,16 @@ type fakeRepo struct {
|
||||
owners map[string]string
|
||||
ownersErr error
|
||||
claimOK map[string]bool // name -> claim succeeds; absent name -> ErrNotFound
|
||||
audits []AuditEntry
|
||||
joins []string
|
||||
// claimQuotaRefuse simulates ClaimServer's atomic quota gate (audit #4)
|
||||
// refusing a name whose advisory pre-check already passed.
|
||||
claimQuotaRefuse map[string]bool
|
||||
// serverResources / resourceUpdates mirror the cached resource columns:
|
||||
// ServerResources is what the resize path reads (to preserve storage), and
|
||||
// UpdateServerResources records the write for assertions.
|
||||
serverResources map[string]ResourceSpec
|
||||
resourceUpdates map[string]ResourceSpec
|
||||
audits []AuditEntry
|
||||
joins []string
|
||||
// create-server seeding (spec §15)
|
||||
seeded map[string]bool // name -> servers row exists
|
||||
aliases map[string]string // subdomain -> bound server name
|
||||
@@ -206,8 +214,9 @@ func newFakeRepo() *fakeRepo {
|
||||
allowlist: map[string]map[string]bool{}, allowUUID: map[string]map[string]bool{},
|
||||
mine: map[string][]MyServerView{},
|
||||
owners: map[string]string{},
|
||||
claimOK: map[string]bool{},
|
||||
seeded: map[string]bool{}, aliases: map[string]string{},
|
||||
claimOK: map[string]bool{}, claimQuotaRefuse: map[string]bool{},
|
||||
serverResources: map[string]ResourceSpec{}, resourceUpdates: map[string]ResourceSpec{},
|
||||
seeded: map[string]bool{}, aliases: map[string]string{},
|
||||
linkCodes: map[string]fakeLinkCode{}, links: map[string]string{},
|
||||
linkAuthSource: map[string]string{},
|
||||
staff: map[string]*StaffUser{},
|
||||
@@ -249,10 +258,13 @@ func (f *fakeRepo) QuotaCheck(_ context.Context, userID string, _ string, _ Reso
|
||||
return f.QuotaAvailable(context.TODO(), userID)
|
||||
}
|
||||
|
||||
func (f *fakeRepo) UpdateServerResources(_ context.Context, _ string, _, _, _ int) error { return nil }
|
||||
func (f *fakeRepo) UpdateServerResources(_ context.Context, name string, cpu, mem, stor int) error {
|
||||
f.resourceUpdates[name] = ResourceSpec{CPUMilli: cpu, MemoryMB: mem, StorageMB: stor}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeRepo) ServerResources(_ context.Context, _ string) (ResourceSpec, error) {
|
||||
return ResourceSpec{}, nil
|
||||
func (f *fakeRepo) ServerResources(_ context.Context, name string) (ResourceSpec, error) {
|
||||
return f.serverResources[name], nil
|
||||
}
|
||||
func (f *fakeRepo) CreateLinkCode(_ context.Context, code, mcUUID, authSource string, expiresAt time.Time) error {
|
||||
f.linkCodes[code] = fakeLinkCode{mcUUID: mcUUID, authSource: authSource, expiresAt: expiresAt}
|
||||
@@ -649,6 +661,9 @@ func (f *fakeRepo) ClaimServer(_ context.Context, n, u string) (bool, error) {
|
||||
if !present {
|
||||
return false, ErrNotFound
|
||||
}
|
||||
if ok && f.claimQuotaRefuse[n] {
|
||||
return false, ErrQuotaExceeded // mirrors the atomic gate losing the race
|
||||
}
|
||||
return ok, nil
|
||||
}
|
||||
func (f *fakeRepo) RecordJoin(_ context.Context, n, uuid string) error {
|
||||
@@ -1783,6 +1798,21 @@ func TestClaimStateMachine(t *testing.T) {
|
||||
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
t.Run("atomic gate refusal -> 403 quota_exceeded", func(t *testing.T) {
|
||||
// The advisory pre-check passed, but ClaimServer's serialized re-check
|
||||
// (audit #4) refuses: the caller must see the same 403, not a 500.
|
||||
repo := newFakeRepo()
|
||||
repo.linked["u1"] = true
|
||||
repo.quota["u1"] = true
|
||||
repo.claimOK["survival"] = true
|
||||
repo.claimQuotaRefuse["survival"] = true
|
||||
api := newTestAPI(repo, newFakeCluster())
|
||||
api.External = staticExternal{p: user}
|
||||
w := do(api.ExternalHandler(), "POST", "/api/v1/servers/survival/claim", "", nil)
|
||||
if w.Code != http.StatusForbidden || decodeErr(t, w) != "quota_exceeded" {
|
||||
t.Fatalf("code = %d body %s, want 403 quota_exceeded", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
t.Run("already claimed -> 409", func(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
repo.linked["u1"] = true
|
||||
|
||||
Reference in new issue
Block a user