Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
209 changes: 209 additions & 0 deletions migrations/0024_mcp_write_scopes.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
PRAGMA defer_foreign_keys = ON;

DROP TRIGGER IF EXISTS mcp_delegation_codes_validate;
DROP TRIGGER IF EXISTS mcp_delegation_redemptions_validate;
DROP TRIGGER IF EXISTS mcp_authorization_revocations_validate;
DROP TRIGGER IF EXISTS mcp_authorization_revocations_apply;

CREATE TABLE mcp_authorization_grants_v02b (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL REFERENCES accounts(id),
agent_id TEXT NOT NULL REFERENCES agents(id),
scopes TEXT NOT NULL CHECK (
scopes IN (
'feed:read',
'feed:read posts:write',
'feed:read replies:write',
'feed:read posts:write replies:write'
)
),
oauth_client_id TEXT NOT NULL CHECK (
length(trim(oauth_client_id)) BETWEEN 1 AND 255
),
oauth_client_label TEXT NOT NULL CHECK (
length(trim(oauth_client_label)) BETWEEN 1 AND 120
),
created_at INTEGER NOT NULL,
last_used_at INTEGER,
expires_at INTEGER,
revoked_at INTEGER,
revoked_reason TEXT,
CHECK (last_used_at IS NULL OR last_used_at >= created_at),
CHECK (expires_at IS NULL OR expires_at > created_at),
CHECK (revoked_at IS NULL OR revoked_at >= created_at),
CHECK (
(revoked_at IS NULL AND revoked_reason IS NULL)
OR
(
revoked_at IS NOT NULL
AND revoked_reason IS NOT NULL
AND length(trim(revoked_reason)) BETWEEN 1 AND 120
)
)
);

INSERT INTO mcp_authorization_grants_v02b (
id, account_id, agent_id, scopes, oauth_client_id,
oauth_client_label, created_at, last_used_at, expires_at,
revoked_at, revoked_reason
)
SELECT
id, account_id, agent_id, scopes, oauth_client_id,
oauth_client_label, created_at, last_used_at, expires_at,
revoked_at, revoked_reason
FROM mcp_authorization_grants;

DROP TABLE mcp_authorization_grants;
ALTER TABLE mcp_authorization_grants_v02b RENAME TO mcp_authorization_grants;

CREATE INDEX mcp_authorization_grants_account_state_idx
ON mcp_authorization_grants (account_id, revoked_at, expires_at, created_at DESC);

CREATE INDEX mcp_authorization_grants_agent_state_idx
ON mcp_authorization_grants (agent_id, revoked_at, expires_at, created_at DESC);

CREATE INDEX mcp_authorization_grants_client_idx
ON mcp_authorization_grants (oauth_client_id, created_at DESC);

CREATE TRIGGER mcp_authorization_grants_validate
BEFORE INSERT ON mcp_authorization_grants
BEGIN
SELECT RAISE(ABORT, 'mcp_authorization_account_inactive')
WHERE NOT EXISTS (
SELECT 1
FROM accounts account
WHERE account.id = NEW.account_id
AND account.status = 'active'
);

SELECT RAISE(ABORT, 'mcp_authorization_agent_not_manageable')
WHERE NOT EXISTS (
SELECT 1
FROM agents agent
WHERE agent.id = NEW.agent_id
AND (
EXISTS (
SELECT 1
FROM agent_memberships membership
WHERE membership.agent_id = agent.id
AND membership.account_id = NEW.account_id
AND membership.role = 'primary_sponsor'
AND membership.revoked_at IS NULL
)
OR EXISTS (
SELECT 1
FROM account_roles role
WHERE role.account_id = NEW.account_id
AND role.role = 'platform_owner'
AND role.revoked_at IS NULL
)
)
);
END;

CREATE TRIGGER mcp_authorization_grants_identity_immutable
BEFORE UPDATE OF account_id, agent_id, scopes, oauth_client_id, oauth_client_label, created_at, expires_at
ON mcp_authorization_grants
BEGIN
SELECT RAISE(ABORT, 'mcp_authorization_grant_identity_immutable');
END;

CREATE TRIGGER mcp_delegation_codes_validate
BEFORE INSERT ON mcp_delegation_codes
BEGIN
SELECT RAISE(ABORT, 'mcp_authorization_grant_unavailable')
WHERE NOT EXISTS (
SELECT 1
FROM mcp_authorization_grants grant_row
WHERE grant_row.id = NEW.grant_id
AND grant_row.revoked_at IS NULL
AND (grant_row.expires_at IS NULL OR grant_row.expires_at > NEW.created_at)
);
END;

CREATE TRIGGER mcp_delegation_redemptions_validate
BEFORE INSERT ON mcp_delegation_redemptions
BEGIN
SELECT RAISE(ABORT, 'invalid_mcp_delegation_code')
WHERE NOT EXISTS (
SELECT 1
FROM mcp_delegation_codes code
JOIN mcp_authorization_grants grant_row
ON grant_row.id = code.grant_id
JOIN accounts account
ON account.id = grant_row.account_id
WHERE code.id = NEW.code_id
AND code.grant_id = NEW.grant_id
AND code.authorization_request_id = NEW.authorization_request_id
AND code.consumed_at IS NULL
AND code.expires_at > NEW.redeemed_at
AND grant_row.revoked_at IS NULL
AND (grant_row.expires_at IS NULL OR grant_row.expires_at > NEW.redeemed_at)
AND account.status = 'active'
AND (
EXISTS (
SELECT 1
FROM agent_memberships membership
WHERE membership.agent_id = grant_row.agent_id
AND membership.account_id = grant_row.account_id
AND membership.role = 'primary_sponsor'
AND membership.revoked_at IS NULL
)
OR EXISTS (
SELECT 1
FROM account_roles role
WHERE role.account_id = grant_row.account_id
AND role.role = 'platform_owner'
AND role.revoked_at IS NULL
)
)
);
END;

CREATE TRIGGER mcp_authorization_revocations_validate
BEFORE INSERT ON mcp_authorization_revocations
BEGIN
SELECT RAISE(ABORT, 'mcp_authorization_grant_unavailable')
WHERE NOT EXISTS (
SELECT 1
FROM mcp_authorization_grants grant_row
WHERE grant_row.id = NEW.grant_id
AND grant_row.revoked_at IS NULL
);

SELECT RAISE(ABORT, 'mcp_authorization_revoke_forbidden')
WHERE NOT EXISTS (
SELECT 1
FROM mcp_authorization_grants grant_row
WHERE grant_row.id = NEW.grant_id
AND (
grant_row.account_id = NEW.actor_account_id
OR EXISTS (
SELECT 1
FROM agent_memberships membership
WHERE membership.agent_id = grant_row.agent_id
AND membership.account_id = NEW.actor_account_id
AND membership.role = 'primary_sponsor'
AND membership.revoked_at IS NULL
)
OR EXISTS (
SELECT 1
FROM account_roles role
WHERE role.account_id = NEW.actor_account_id
AND role.role = 'platform_owner'
AND role.revoked_at IS NULL
)
)
);
END;

CREATE TRIGGER mcp_authorization_revocations_apply
AFTER INSERT ON mcp_authorization_revocations
BEGIN
UPDATE mcp_authorization_grants
SET revoked_at = NEW.revoked_at,
revoked_reason = trim(NEW.reason)
WHERE id = NEW.grant_id;
END;

PRAGMA defer_foreign_keys = OFF;
22 changes: 20 additions & 2 deletions scripts/orbit-browser-tests.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ if (errors.length === 0) {
authorizationRequest: {
id: browserMcpAuthorizationRequestId,
oauthClient: { id: 'chatgpt-browser-client', label: 'ChatGPT' },
scopes: ['feed:read'],
scopes: ['feed:read', 'posts:write', 'replies:write'],
issuedAt: Date.now(),
expiresAt: Date.now() + 10 * 60 * 1000,
},
Expand Down Expand Up @@ -692,6 +692,11 @@ if (errors.length === 0) {
hash: location.hash,
client: document.querySelector('#mcp-client-summary')?.textContent?.trim(),
scope: document.querySelector('#mcp-scope-summary')?.textContent?.trim(),
scopeOptions: [...document.querySelectorAll('#mcp-scope-options input[data-mcp-scope]')].map((input) => ({
scope: input.dataset.mcpScope,
checked: input.checked,
disabled: input.disabled,
})),
options: [...document.querySelectorAll('#mcp-agent-select option')].map((option) => ({
value: option.value,
label: option.textContent?.trim(),
Expand All @@ -700,12 +705,21 @@ if (errors.length === 0) {
}));
check(consentState.hash === '', 'MCP owner consentinde ticket URL fragmentından temizlenmedi.');
check(consentState.client?.includes('ChatGPT'), 'MCP consent istemci adını göstermiyor.');
check(consentState.scope?.includes('feed:read'), 'MCP consent least-privilege kapsamını göstermiyor.');
check(consentState.scope?.includes('isteğe bağlı yazma'), 'MCP consent kapsam daraltma açıklamasını göstermiyor.');
check(consentState.scopeOptions.length === 3, 'MCP consent istenen kapsamları ayrı seçenekler olarak göstermiyor.');
const feedScope = consentState.scopeOptions.find((option) => option.scope === 'feed:read');
const postScope = consentState.scopeOptions.find((option) => option.scope === 'posts:write');
const replyScope = consentState.scopeOptions.find((option) => option.scope === 'replies:write');
check(feedScope?.checked === true && feedScope?.disabled === true, 'MCP consent zorunlu feed:read kapsamını sabitlemiyor.');
check(postScope?.checked === false && postScope?.disabled === false, 'MCP consent posts:write kapsamını varsayılan kapalı göstermiyor.');
check(replyScope?.checked === false && replyScope?.disabled === false, 'MCP consent replies:write kapsamını varsayılan kapalı göstermiyor.');
check(consentState.options.length === browserAgents.length, 'MCP consent yönetilebilir ajan listesini eksik gösteriyor.');
check(consentState.options.some((option) => option.value === 'agent-selene'), 'MCP consent Selene seçimini sunmuyor.');
check(consentState.approveDisabled === false, 'MCP consent onay düğmesi kullanılabilir değil.');

await page.locator('#mcp-agent-select').selectOption('agent-selene');
await page.locator('#mcp-scope-options input[data-mcp-scope="posts:write"]').check();
await page.locator('#mcp-scope-options input[data-mcp-scope="replies:write"]').check();
await page.locator('#mcp-approve').click();
await page.waitForURL((url) => (
url.origin === 'https://mcp.orbit.sametbasbug.dev'
Expand All @@ -717,6 +731,10 @@ if (errors.length === 0) {
check(browserMcpAuthorizationBodies.length === 1, 'MCP consent grant isteğini tam bir kez göndermedi.');
check(browserMcpAuthorizationBodies[0]?.agentId === 'agent-selene', 'MCP consent seçilen ajanı grant isteğine bağlamadı.');
check(browserMcpAuthorizationBodies[0]?.ticket === browserMcpTicket, 'MCP consent imzalı ticketı grant isteğine taşımadı.');
check(
JSON.stringify(browserMcpAuthorizationBodies[0]?.scopes) === JSON.stringify(['feed:read', 'posts:write', 'replies:write']),
'MCP consent yalnız açıkça seçilen kapsamları grant isteğine taşımadı.',
);
check(pageErrors.length === 0, `MCP owner consentinde sayfa hatası: ${pageErrors.join(' | ')}`);
await context.close();
}
Expand Down
13 changes: 11 additions & 2 deletions scripts/orbit-d1-test-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
type D1DatabaseLike,
} from '../src/server/repositories/d1/d1-foundation-repository';
import { createDynamicBackup } from '../src/server/backup/dynamic-backup';
import type { McpAuthorizationScope } from '../src/server/identity/mcp-authorization-scopes';
import { D1McpAuthorizationRepository } from '../src/server/repositories/d1/d1-mcp-authorization-repository';

interface TestStatement {
Expand Down Expand Up @@ -45,6 +46,14 @@ function numberValue(data: Record<string, unknown>, key: string): number {
return value;
}

function stringArrayValue(data: Record<string, unknown>, key: string): string[] {
const value = data[key];
if (!Array.isArray(value) || value.length === 0 || value.some((item) => typeof item !== 'string')) {
throw new Error(`missing_${key}`);
}
return value as string[];
}

async function count(db: TestDatabase, table: string, column: string, value: string): Promise<number> {
const allowed = new Set([
'accounts:id',
Expand Down Expand Up @@ -233,13 +242,13 @@ async function handleAction(body: ActionRequest, env: Environment): Promise<Resp
case 'createMcpGrant': {
const now = numberValue(data, 'now');
const grantExpiresAt = data.grantExpiresAt;
const scope = stringValue(data, 'scope') as 'feed:read';
const scopes = stringArrayValue(data, 'scopes') as McpAuthorizationScope[];
await mcpRepository.createGrantWithCode({
grant: {
id: stringValue(data, 'grantId'),
accountId: stringValue(data, 'accountId'),
agentId: stringValue(data, 'agentId'),
scopes: [scope],
scopes,
oauthClientId: stringValue(data, 'oauthClientId'),
oauthClientLabel: stringValue(data, 'oauthClientLabel'),
createdAt: now,
Expand Down
53 changes: 45 additions & 8 deletions scripts/orbit-mcp-authorization-tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ function grantData(prefix: string, accountId: string, agentId: string, now: numb
grantId: `${prefix}-grant`,
accountId,
agentId,
scope: 'feed:read',
scopes: ['feed:read'],
oauthClientId: `${prefix}-client`,
oauthClientLabel: `${prefix} ChatGPT client`,
codeId: `${prefix}-code`,
Expand Down Expand Up @@ -227,7 +227,36 @@ describe('Orbit MCP authorization foundation', { concurrency: false }, () => {
assert.deepEqual(listed.grants.map((grant) => grant.id), [input.grantId]);
});

test('unrelated accounts and elevated scopes are rejected', async () => {
test('canonical read and write scope combinations persist without widening old grants', async () => {
const now = Date.now();
const accountId = 'mcp-scope-sponsor';
const agentId = 'mcp-scope-agent';
await callAction('seedMcpAgent', { accountId, agentId, now });

const combinations = [
['feed:read'],
['feed:read', 'posts:write'],
['feed:read', 'replies:write'],
['feed:read', 'posts:write', 'replies:write'],
];
for (const [index, scopes] of combinations.entries()) {
const input = {
...grantData(`mcp-scope-${index}`, accountId, agentId, now + index + 1),
scopes,
};
const created = await callAction<{ grant: { scopes: string[] } }>('createMcpGrant', input, 201);
assert.deepEqual(created.grant.scopes, scopes);
}

const reordered = {
...grantData('mcp-scope-reordered', accountId, agentId, now + 10),
scopes: ['replies:write', 'feed:read', 'posts:write'],
};
const canonical = await callAction<{ grant: { scopes: string[] } }>('createMcpGrant', reordered, 201);
assert.deepEqual(canonical.grant.scopes, ['feed:read', 'posts:write', 'replies:write']);
});

test('unrelated accounts and invalid scope sets are rejected', async () => {
const now = Date.now();
const sponsorId = 'mcp-policy-sponsor';
const intruderId = 'mcp-policy-intruder';
Expand All @@ -239,12 +268,20 @@ describe('Orbit MCP authorization foundation', { concurrency: false }, () => {
const unrelatedError = await callAction<{ error: string }>('createMcpGrant', unrelated, 409);
assert.match(unrelatedError.error, /mcp_authorization_agent_not_manageable/u);

const elevated = {
...grantData('mcp-elevated', sponsorId, agentId, now + 2),
scope: 'records:write',
};
const scopeError = await callAction<{ error: string }>('createMcpGrant', elevated, 409);
assert.match(scopeError.error, /mcp_authorization_scope_invalid/u);
const invalidSets = [
['records:write'],
['posts:write'],
['feed:read', 'feed:read'],
['feed:read', 'records:write'],
];
for (const [index, scopes] of invalidSets.entries()) {
const invalid = {
...grantData(`mcp-invalid-scope-${index}`, sponsorId, agentId, now + index + 2),
scopes,
};
const scopeError = await callAction<{ error: string }>('createMcpGrant', invalid, 409);
assert.match(scopeError.error, /mcp_authorization_scope_invalid/u);
}
});

test('a platform owner may authorize a managed agent without becoming its sponsor', async () => {
Expand Down
Loading