fix(ai): require member identity within a shared org for report routing, overlays, and coverage

Codex review round 7 on e90a72bdd flagged that broker usage-report
matching, header-overlay keying, and omp-usage coverage all treated a
matching organization as a sufficient match. Two Team members share the
org id while drawing on per-user pools, so the first same-org report
(or a lone sibling report) was handed to the wrong member. The org is
now a gate: within the same-org subset the member's own base identity
(account/email/project) must still match, with org-only entities (no
base identifiers) matching on the org alone when unambiguous. The
overlay merger (findMatchingReportIndex) had the identical same-org
flaw and receives the symmetric fix. Org-presence-mismatch semantics
are unchanged: org-scoped vs org-less stays fall-through/unreported,
and both-org-less keeps the legacy base-identity fallback.
This commit is contained in:
chan1103
2026-07-11 19:01:57 +09:00
parent 45203a1b56
commit e095af3be0
6 changed files with 210 additions and 30 deletions
+1
View File
@@ -12,6 +12,7 @@
- Fixed usage-path OAuth refreshes on broker-backed credentials persisting the rotated token into the wrong row: the shared `REMOTE_REFRESH_SENTINEL` refresh value is no longer treated as row identity when locating the row to update, so with two same-email organizations the refreshed token lands on the org that was actually refreshed.
- Fixed the org qualifier only riding on email-based Anthropic identities: when the login email cannot be recovered (token response omits it and the bootstrap fallback fails), the account/project fallback keys are now org-qualified too — the account UUID is identical across the orgs of one login account, so a second subscription could otherwise still replace the first on the no-email path. The same one-way legacy upgrade applies to bare account/project keys, usage-report dedupe falls back to the org-qualified account for no-email reports, and broker report routing is org-decisive on either side (an org-less legacy credential no longer receives an org-attributed sibling's pool).
- Fixed an org-only Anthropic credential row (stored when login recovered neither email nor account) never being claimed by a later login of the same organization that does recover the identity — the row is now upgraded and re-keyed in place instead of duplicating the subscription.
- Fixed broker usage report routing and header-overlay keying/merging to require the member's own identity (account/email/project) within a shared organization, so two Team members sharing one org id no longer receive each other's per-user pools — a member's missing report surfaces as "no usage data" instead of a sibling's numbers, while an org-only credential still matches its lone same-org report.
## [16.4.3] - 2026-07-11
+51 -20
View File
@@ -148,14 +148,19 @@ function usageOverlayKey(
// Org first: one account email can hold several organizations (Anthropic
// Team seat + personal Max), each with its own limit pools. Keying the
// overlay by account/email would merge the two pools' header ingests.
const orgId = ids.orgId?.trim().toLowerCase();
if (orgId) return `${provider}\0org:${orgId}`;
// But the org alone is not enough either: two Team members share the org
// id while drawing on per-user pools, so the key stays qualified by the
// member's own base identity whenever one is known.
let base: string | undefined;
const accountId = ids.accountId?.trim().toLowerCase();
if (accountId) return `${provider}\0account:${accountId}`;
const email = ids.email?.trim().toLowerCase();
if (email) return `${provider}\0email:${email}`;
const projectId = ids.projectId?.trim().toLowerCase();
if (projectId) return `${provider}\0project:${projectId}`;
if (accountId) base = `account:${accountId}`;
else if (email) base = `email:${email}`;
else if (projectId) base = `project:${projectId}`;
const orgId = ids.orgId?.trim().toLowerCase();
if (orgId) return base ? `${provider}\0org:${orgId}|${base}` : `${provider}\0org:${orgId}`;
if (base) return `${provider}\0${base}`;
return undefined;
}
@@ -994,28 +999,43 @@ function matchUsageReport(reports: UsageReport[], provider: Provider, credential
// Max exhausted via Team's report, or rank a legacy row on a sibling's
// numbers).
const orgId = credential.orgId?.trim().toLowerCase();
const accountId = credential.accountId?.trim().toLowerCase();
const email = credential.email?.trim().toLowerCase();
const projectId = credential.projectId?.trim().toLowerCase();
if (orgId) {
const sameOrg: UsageReport[] = [];
let sawReportOrg = false;
for (const report of all) {
const metaOrg = readMetadataString((report.metadata ?? {}) as Record<string, unknown>, "orgId");
if (metaOrg) {
sawReportOrg = true;
if (metaOrg.toLowerCase() === orgId) return report;
if (metaOrg.toLowerCase() === orgId) sameOrg.push(report);
}
}
// Org-attributed reports exist but none is ours: report "no usage data"
// rather than mis-attributing another org's pool. When NO report carries
// an org (legacy broker aggregate), fall through with all candidates.
if (sawReportOrg) return null;
// Org-attributed reports exist: the shared org is a GATE, not a match.
// Two Team members share the org id while drawing on per-user pools,
// so the credential's own base identity must still line up inside the
// same-org subset — a lone sibling report is NOT ours. An org-only
// credential (no base identifiers) takes the lone same-org report and
// treats several as ambiguous. None in our org → "no usage data"
// rather than mis-attributing another org's pool. When NO report
// carries an org (legacy broker aggregate), fall through with all
// candidates.
if (sawReportOrg) {
if (accountId || email || projectId) {
for (const report of sameOrg) {
if (reportMatchesIdentity(report, accountId, email, projectId)) return report;
}
return null;
}
return sameOrg.length === 1 ? sameOrg[0]! : null;
}
}
const candidates = orgId
? all
: all.filter(report => !readMetadataString((report.metadata ?? {}) as Record<string, unknown>, "orgId"));
if (candidates.length === 0) return null;
if (candidates.length === 1) return candidates[0];
const accountId = credential.accountId?.trim().toLowerCase();
const email = credential.email?.trim().toLowerCase();
const projectId = credential.projectId?.trim().toLowerCase();
for (const report of candidates) {
if (reportMatchesIdentity(report, accountId, email, projectId)) return report;
}
@@ -1029,19 +1049,33 @@ function findMatchingReportIndex(reports: UsageReport[], overlay: UsageReport):
if (all.length === 0) return -1;
const metadata = (overlay.metadata ?? {}) as Record<string, unknown>;
// Org precedence — mirror matchUsageReport: an org-attributed overlay may
// only merge into the report of the SAME org, and an org-less overlay may
// only merge into an org-less report.
// only merge into a report of the SAME org, and an org-less overlay may
// only merge into an org-less report. Within the same org the overlay's
// base identity must still match — two Team members' reports share the
// org id but must not swallow each other's header ingests.
const overlayOrg = readMetadataString(metadata, "orgId")?.toLowerCase();
const accountId = readMetadataString(metadata, "accountId")?.toLowerCase();
const email = readMetadataString(metadata, "email")?.toLowerCase();
const projectId = readMetadataString(metadata, "projectId")?.toLowerCase();
if (overlayOrg) {
const sameOrg: { report: UsageReport; index: number }[] = [];
let sawReportOrg = false;
for (const candidate of all) {
const candidateOrg = readMetadataString((candidate.report.metadata ?? {}) as Record<string, unknown>, "orgId");
if (candidateOrg) {
sawReportOrg = true;
if (candidateOrg.toLowerCase() === overlayOrg) return candidate.index;
if (candidateOrg.toLowerCase() === overlayOrg) sameOrg.push(candidate);
}
}
if (sawReportOrg) return -1;
if (sawReportOrg) {
if (accountId || email || projectId) {
for (const candidate of sameOrg) {
if (reportMatchesIdentity(candidate.report, accountId, email, projectId)) return candidate.index;
}
return -1;
}
return sameOrg.length === 1 ? sameOrg[0]!.index : -1;
}
}
const candidates = overlayOrg
? all
@@ -1050,9 +1084,6 @@ function findMatchingReportIndex(reports: UsageReport[], overlay: UsageReport):
);
if (candidates.length === 0) return -1;
if (candidates.length === 1) return candidates[0]!.index;
const accountId = readMetadataString(metadata, "accountId")?.toLowerCase();
const email = readMetadataString(metadata, "email")?.toLowerCase();
const projectId = readMetadataString(metadata, "projectId")?.toLowerCase();
for (const candidate of candidates) {
if (reportMatchesIdentity(candidate.report, accountId, email, projectId)) return candidate.index;
}
+106
View File
@@ -505,6 +505,112 @@ describe("RemoteAuthCredentialStore + AuthStorage integration", () => {
}
});
test("getUsageReport gates same-org siblings on the member's own identity", async () => {
// Two Team members share the org id but draw on per-user pools: the
// shared org is a gate, not a match, so Bob must never receive Alice's
// report just because it is the first (or only) same-org candidate.
const brokerClient = new AuthBrokerClient({ url: "http://127.0.0.1:9", token: "unused" });
const now = Date.now();
const makeMemberCredential = (name: string, orgId?: string) => ({
type: "oauth" as const,
access: `remote-access-${name}`,
refresh: REMOTE_REFRESH_SENTINEL,
expires: now + 120_000,
...(name === "org-only" ? {} : { accountId: `account-${name}`, email: `${name}@example.com` }),
orgId,
});
const makeMemberReport = (
name: string,
orgId: string,
usedFraction: number,
status: "ok" | "exhausted",
): UsageReport => ({
provider: "anthropic",
fetchedAt: now,
limits: [
{
id: "anthropic:5h",
label: "Claude 5 Hour",
scope: { provider: "anthropic", windowId: "5h" },
window: { id: "5h", label: "5 Hour" },
amount: { used: usedFraction * 100, limit: 100, usedFraction, unit: "percent" },
status,
},
],
metadata: { email: `${name}@example.com`, accountId: `account-${name}`, orgId },
});
// Bob's report deliberately precedes Alice's so a first-same-org match
// would hand his pool to Alice; org-duo holds only Dave's report.
vi.spyOn(brokerClient, "fetchUsage").mockResolvedValue({
generatedAt: now,
reports: [
makeMemberReport("bob", "org-team", 0.1, "ok"),
makeMemberReport("alice", "org-team", 1, "exhausted"),
makeMemberReport("dave", "org-duo", 0.5, "ok"),
],
});
const remoteStore = new RemoteAuthCredentialStore({
client: brokerClient,
streamSnapshots: false,
initialSnapshot: {
generation: 1,
generatedAt: now,
serverNowMs: now,
refresher: { enabled: false, intervalMs: 0, skewMs: 0, nextSweepInMs: Number.MAX_SAFE_INTEGER },
credentials: [],
},
});
try {
// Each member routes to their OWN pool inside the shared org.
const aliceReport = await remoteStore.getUsageReport("anthropic", makeMemberCredential("alice", "org-team"));
expect(aliceReport?.metadata?.accountId).toBe("account-alice");
expect(requireLimit(aliceReport!, "anthropic:5h").status).toBe("exhausted");
const bobReport = await remoteStore.getUsageReport("anthropic", makeMemberCredential("bob", "org-team"));
expect(bobReport?.metadata?.accountId).toBe("account-bob");
expect(requireLimit(bobReport!, "anthropic:5h").status).toBe("ok");
// Erin's own report is missing: the lone same-org sibling report
// (Dave's) must not stand in for hers — "no usage data" is correct.
expect(await remoteStore.getUsageReport("anthropic", makeMemberCredential("erin", "org-duo"))).toBeNull();
// An org-only credential (no base identifiers) still matches on the
// org alone, but only when the same-org report is unambiguous.
const duoReport = await remoteStore.getUsageReport("anthropic", makeMemberCredential("org-only", "org-duo"));
expect(duoReport?.metadata?.accountId).toBe("account-dave");
expect(await remoteStore.getUsageReport("anthropic", makeMemberCredential("org-only", "org-team"))).toBeNull();
// Header-ingest overlays partition per member too: Alice's ingest
// must merge into HER aggregate row, not Bob's earlier same-org row.
const overlay: UsageReport = {
provider: "anthropic",
fetchedAt: now,
limits: [
{
id: "anthropic:5h",
label: "Claude 5 Hour",
scope: { provider: "anthropic", windowId: "5h" },
window: { id: "5h", label: "5 Hour" },
amount: { used: 90, limit: 100, usedFraction: 0.9, unit: "percent" },
status: "ok",
},
],
metadata: { email: "alice@example.com", accountId: "account-alice", orgId: "org-team" },
};
expect(remoteStore.ingestUsageReport("anthropic", makeMemberCredential("alice", "org-team"), overlay)).toBe(
true,
);
const merged = await remoteStore.fetchUsageReports();
const mergedAlice = merged?.find(report => report.metadata?.accountId === "account-alice");
const mergedBob = merged?.find(report => report.metadata?.accountId === "account-bob");
expect(requireLimit(mergedAlice!, "anthropic:5h").amount.used).toBe(90);
expect(requireLimit(mergedBob!, "anthropic:5h").amount.used).toBe(10);
const bobAfterIngest = await remoteStore.getUsageReport("anthropic", makeMemberCredential("bob", "org-team"));
expect(requireLimit(bobAfterIngest!, "anthropic:5h").amount.used).toBe(10);
} finally {
remoteStore.close();
}
});
test("RemoteAuthCredentialStore reads snapshot blocks and applies upserts before broker acknowledgement", () => {
const futureBlock = Date.now() + 60_000;
const laterBlock = futureBlock + 60_000;
+1
View File
@@ -48,6 +48,7 @@
- The status line invalidates its cached usage when the session rotates to a different Anthropic organization (previously the old subscription's quota could linger for the cache TTL), and `omp auth-gateway check` labels each credential with its organization so a failing row says which subscription needs re-login.
- `omp usage` "no usage data" attribution is org-decisive whenever either the stored account or a report carries an organization: an org-less legacy credential whose own fetch failed is no longer hidden by an org-attributed sibling report sharing the same email.
- Active-account matching for `/usage`, `/logout`, and `omp token --list` now treats a shared organization as a qualifier rather than a match: two Anthropic Team seats in one org (same org id, per-user pools) no longer flag each other's rows or reports as "in use by this session" — the base identity (account/email/project) is still required, with org-only sessions matching on the org alone.
- `omp usage` "no usage data" coverage now requires the member's own identity within a shared organization: a sibling Team member's same-org report no longer counts as coverage for an account whose own report is missing, while an org-only account remains covered by any same-org report.
## [16.4.3] - 2026-07-11
+23 -10
View File
@@ -303,20 +303,33 @@ export function collectUnreportedAccounts(
// org-scoped account is covered only by its own org's report, and an
// org-less legacy account is never covered by an org-attributed sibling
// report — its own fetch failing must surface as "no usage data". The
// email/account fallback below applies only when both sides are
// org-less.
// shared org is a GATE, not a match: two Team members share the org id
// while drawing on per-user pools, so coverage also requires the
// account's own base identity inside the same-org subset (an org-only
// account, with no base identifiers, is covered by any same-org
// report). The email/account fallback below applies only when both
// sides are org-less.
const accountOrg = account.orgId?.toLowerCase();
const reportedOrgs = new Set<string>();
for (const report of providerReports) {
const metaOrg = report.metadata?.orgId;
if (typeof metaOrg === "string" && metaOrg) reportedOrgs.add(metaOrg.toLowerCase());
}
if (accountOrg || reportedOrgs.size > 0) {
return !(accountOrg !== undefined && reportedOrgs.has(accountOrg));
}
const ids = [account.email, account.accountId, account.projectId]
.filter((value): value is string => typeof value === "string" && value.length > 0)
.map(value => value.toLowerCase());
const sameOrgReports: UsageReport[] = [];
let sawReportOrg = false;
for (const report of providerReports) {
const metaOrg = report.metadata?.orgId;
if (typeof metaOrg === "string" && metaOrg) {
sawReportOrg = true;
if (accountOrg !== undefined && metaOrg.toLowerCase() === accountOrg) sameOrgReports.push(report);
}
}
if (accountOrg || sawReportOrg) {
if (!accountOrg || sameOrgReports.length === 0) return true;
if (ids.length === 0) return false;
return !sameOrgReports.some(report => {
const identifiers = reportIdentifiers(report);
return ids.some(id => identifiers.has(id));
});
}
if (ids.length === 0) return false;
const reported = new Set<string>();
let anyIdentified = false;
@@ -163,6 +163,34 @@ describe("collectUnreportedAccounts", () => {
const orglessAccounts: UsageAccountIdentity[] = [{ provider: "anthropic", type: "oauth", email: shared }];
expect(collectUnreportedAccounts([orglessReport], orglessAccounts)).toEqual([]);
});
it("gates same-org coverage on the member's own identity", () => {
const org = "org-team";
const alice: UsageAccountIdentity = {
provider: "anthropic",
type: "oauth",
email: "alice@example.test",
accountId: "account-alice",
orgId: org,
};
const bob: UsageAccountIdentity = {
provider: "anthropic",
type: "oauth",
email: "bob@example.test",
accountId: "account-bob",
orgId: org,
};
const orgOnly: UsageAccountIdentity = { provider: "anthropic", type: "oauth", orgId: org };
const aliceReport = {
...makeReport("anthropic", alice.email!, []),
metadata: { email: alice.email, accountId: alice.accountId, orgId: org },
};
// Alice reported, Bob not: the sibling's same-org report must not count
// as Bob's coverage — two Team members share the org id but draw on
// per-user pools. An org-only account (no base identifiers to gate on)
// stays covered by any same-org report.
expect(collectUnreportedAccounts([aliceReport], [alice, bob, orgOnly])).toEqual([bob]);
});
});
describe("formatUsageBreakdown", () => {