fix(settings): derive API-key scope groups and tool counts from the scope catalogue (#1924)
The API-key settings panel carried a hand-copied list of scope groups that
had drifted to 24 of the 30 scopes in API_KEY_SCOPES: articles:read/write,
companies:write and the three reconciliation scopes were missing, so a key
minted in the dashboard could not call gnubok_create_company, the article
tools, the seven reconciliation tools or the matching v1 endpoints. The
per-scope "N verktyg" counts in the panel and in the API_KEY_SCOPES
descriptions were hand-maintained and wrong (reports:read said 18, actual
30; bookkeeping:write said 11, actual 22).
- Move the pure scope catalogue (API_KEY_SCOPES, scope lists, SCOPE_GROUPS,
TOOL_SCOPE_MAP) into lib/auth/scope-catalog.ts with no server imports, so
the client-side panel can bundle it. api-keys.ts re-exports everything,
so existing imports are unchanged.
- SCOPE_GROUPS becomes a list of { domain, label, scopes } covering every
scope (reconciliation has three), shared by the panel and the OAuth
consent page. scopeKind() replaces the ad hoc suffix checks.
- TOOL_COUNT_BY_SCOPE is derived from TOOL_SCOPE_MAP at module load; the
hand-written counts are removed from the catalogue descriptions.
- The panel renders groups and cards from the catalogue; i18n keys are
derived from domain and scope id. The "(REST API)" heading suffix is
computed from the counts instead of baked into the labels.
- New unit test asserts every scope belongs to exactly one group and that
counts equal TOOL_SCOPE_MAP occurrences.
- New sv/en strings for the articles, companies:write and reconciliation
scopes.
Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
f338850bd0
commit
b8605aabfc
@@ -1253,6 +1253,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
|
||||
[2026-08-26] No ratchet on direct requireAuth() calls in app/api: requireAuth() is the MFA (AAL2) guard withRouteContext itself calls, and .claude/rules/api-routes.md sanctions it for routes without a company context (onboarding, account, user prefs). The 20 remaining direct callers skip request ids and the canonical envelope, not MFA; migrating them is a consistency campaign, not a security fix, so it was not folded into the bypass PR.
|
||||
[2026-08-26] defer_invoice_booking (#967) now gates booking on every door, not just the dashboard: MCP send_invoice / mark_invoice_sent / create_supplier_invoice_from_inbox, v1 invoices send / mark-sent and supplier-invoices create, and the inbox convert route all checked accounting_method === 'accrual' and posted a verifikat at issue for deferred companies. All six now call booksInvoicesOnIssue() (lib/bookkeeping/booking-mode.ts), the same helper the dashboard routes use, so the setting has one meaning. No data repair attempted: vouchers already posted for deferred companies through these doors are legitimate entries and stay.
|
||||
[2026-08-20] The swedish-e-invoicing skill now names Upphandlingsmyndigheten as Sweden Peppol Authority across all eight files, not just the one that was flagged: the handover completed 1 July 2026 (regeringsbeslut Fi2025/01826) and the skill was written in future tense, so a partial fix would have left the atom internally contradictory and still pointed agents at peppol@digg.se. Four digg.se URLs were repointed to their verified 301 targets on upphandlingsmyndigheten.se; the fifth, DIGG Peppol testbadd, is a hard 404 with no redirect and no successor page at the new authority, so it was replaced with the SFTI Validex verification service (https://sfti.validex.net/) rather than left dead or guessed at. Historical attributions (Q4 2025 traffic statistics, the 0007:2021006883 Peppol-ID example) deliberately still say DIGG because they were accurate when published.
|
||||
[2026-08-26] API-key scope pickers (settings panel + OAuth consent) render from one SCOPE_GROUPS list in lib/auth/scope-catalog.ts, with MCP tool counts derived from TOOL_SCOPE_MAP at module load: the panel's hand-copied group list had drifted to 24 of 30 scopes (no articles, companies:write or reconciliation, so dashboard-minted keys could not call those tools) and every per-scope count was stale. The catalogue is a separate pure module rather than api-keys.ts itself because the panel is a client component and api-keys.ts imports crypto and the service-role client; a unit test enforces one-group-per-scope so the next scope cannot silently vanish from the pickers.
|
||||
[2026-08-26] Webhook event catalogue lives in lib/webhooks/public-events.ts (grouped, with docs prose) and the fan-out handler set, the v1 create enum (so the OpenAPI spec and skills/accounted-api), and the docs page all derive from it: the enum and the docs had drifted to 24 of the 28 events the handler delivered, so the four reconciliation.* events were rejected at subscribe time. No API_V1_VERSION bump: the changelog already lists them as additive.
|
||||
[2026-08-26] Removed the phantom V1_ENDPOINT_SCOPES entries GET /api/v1/openapi.yaml, GET /api/v1/companies/:companyId and GET /api/v1/companies/:companyId/events instead of building the routes: no route file, registry entry, docs, skill or test referenced them, and the new scope-registry-parity test needs the map to describe only what exists. A company-detail GET can be added later with its entry in the same PR.
|
||||
[2026-08-26] gnubok_connect_bank / gnubok_connect_skatteverket moved from catalogVisibility 'search' to the default catalog: Claude.ai can only invoke tools present in tools/list, so search-only tools are discover-only there and the onboarding skill's steps 3-4 dead-ended on client-side tool-not-found (verified via event_log: the server never received the calls). Search-only visibility remains fine for tools an agent reads about before asking the user, but anything a skill instructs the agent to CALL must be in the default catalog.
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
API_KEY_SCOPES,
|
||||
DEFAULT_OAUTH_SCOPES,
|
||||
SCOPE_GROUPS,
|
||||
scopeKind,
|
||||
validateScopes,
|
||||
type ApiKeyScope,
|
||||
} from '@/lib/auth/api-keys'
|
||||
@@ -838,13 +839,10 @@ function renderScopeCheckboxes(
|
||||
|
||||
for (const group of SCOPE_GROUPS) {
|
||||
const rows: string[] = []
|
||||
if (group.read && ceiling.has(group.read)) {
|
||||
rows.push(scopeRow(group.read, preChecked.has(group.read), 'read'))
|
||||
renderedInGroups.add(group.read)
|
||||
}
|
||||
if (group.write && ceiling.has(group.write)) {
|
||||
rows.push(scopeRow(group.write, preChecked.has(group.write), 'write'))
|
||||
renderedInGroups.add(group.write)
|
||||
for (const scope of group.scopes) {
|
||||
if (!ceiling.has(scope)) continue
|
||||
rows.push(scopeRow(scope, preChecked.has(scope), scopeKind(scope)))
|
||||
renderedInGroups.add(scope)
|
||||
}
|
||||
if (rows.length > 0) {
|
||||
groups.push(
|
||||
@@ -853,11 +851,11 @@ function renderScopeCheckboxes(
|
||||
}
|
||||
}
|
||||
|
||||
// Defense in depth: the catalogue test guarantees full group coverage, so
|
||||
// this bucket is empty unless a scope ships without a group.
|
||||
const remaining = ALL_SCOPES.filter(s => ceiling.has(s) && !renderedInGroups.has(s))
|
||||
if (remaining.length > 0) {
|
||||
const rows = remaining.map((s) =>
|
||||
scopeRow(s, preChecked.has(s), s.endsWith(':write') || s.endsWith(':manage') || s.endsWith(':approve') ? 'write' : 'read')
|
||||
)
|
||||
const rows = remaining.map((s) => scopeRow(s, preChecked.has(s), scopeKind(s)))
|
||||
groups.push(
|
||||
`<div class="scope-group"><div class="scope-group-title">Övriga</div>${rows.join('')}</div>`
|
||||
)
|
||||
|
||||
@@ -31,133 +31,28 @@ import { cn, formatDateLong } from '@/lib/utils'
|
||||
import { copyToClipboard } from '@/lib/browser/copy-to-clipboard'
|
||||
import { getBranding } from '@/lib/branding/service'
|
||||
import { ILLUSTRATIONS, illustrationSrc } from '@/components/onboarding/onboarding-illustrations'
|
||||
import { STAGING_SCOPES } from '@/lib/auth/api-keys'
|
||||
import type { ApiKeyScope } from '@/lib/auth/api-keys'
|
||||
import {
|
||||
ALL_SCOPES,
|
||||
SCOPE_GROUPS,
|
||||
STAGING_SCOPES,
|
||||
TOOL_COUNT_BY_SCOPE,
|
||||
scopeKind,
|
||||
type ApiKeyScope,
|
||||
type ScopeGroup,
|
||||
} from '@/lib/auth/scope-catalog'
|
||||
|
||||
const branding = getBranding()
|
||||
const connectorName = branding.appName.toLowerCase()
|
||||
|
||||
type ScopeEntry = {
|
||||
scope: ApiKeyScope
|
||||
labelKey: string
|
||||
/** Number of MCP tools gated by this scope. 0 = REST-API-only scope. */
|
||||
tools: number
|
||||
}
|
||||
|
||||
type ScopeGroup = {
|
||||
domain: string
|
||||
labelKey: string
|
||||
read: ScopeEntry | null
|
||||
write: ScopeEntry | null
|
||||
}
|
||||
|
||||
const SCOPE_GROUPS: ScopeGroup[] = [
|
||||
{
|
||||
domain: 'transactions',
|
||||
labelKey: 'group_transactions',
|
||||
read: { scope: 'transactions:read', labelKey: 'scope_transactions_read', tools: 8 },
|
||||
write: { scope: 'transactions:write', labelKey: 'scope_transactions_write', tools: 8 },
|
||||
},
|
||||
{
|
||||
domain: 'customers',
|
||||
labelKey: 'group_customers',
|
||||
read: { scope: 'customers:read', labelKey: 'scope_customers_read', tools: 1 },
|
||||
write: { scope: 'customers:write', labelKey: 'scope_customers_write', tools: 1 },
|
||||
},
|
||||
{
|
||||
domain: 'invoices',
|
||||
labelKey: 'group_invoices',
|
||||
read: { scope: 'invoices:read', labelKey: 'scope_invoices_read', tools: 1 },
|
||||
write: { scope: 'invoices:write', labelKey: 'scope_invoices_write', tools: 6 },
|
||||
},
|
||||
{
|
||||
domain: 'suppliers',
|
||||
labelKey: 'group_suppliers',
|
||||
read: { scope: 'suppliers:read', labelKey: 'scope_suppliers_read', tools: 2 },
|
||||
write: { scope: 'suppliers:write', labelKey: 'scope_suppliers_write', tools: 3 },
|
||||
},
|
||||
{
|
||||
domain: 'reports',
|
||||
labelKey: 'group_reports',
|
||||
read: { scope: 'reports:read', labelKey: 'scope_reports_read', tools: 18 },
|
||||
write: null,
|
||||
},
|
||||
{
|
||||
domain: 'bookkeeping',
|
||||
labelKey: 'group_bookkeeping',
|
||||
read: null,
|
||||
write: { scope: 'bookkeeping:write', labelKey: 'scope_bookkeeping_write', tools: 11 },
|
||||
},
|
||||
{
|
||||
domain: 'payroll',
|
||||
labelKey: 'group_payroll',
|
||||
read: { scope: 'payroll:read', labelKey: 'scope_payroll_read', tools: 3 },
|
||||
write: { scope: 'payroll:write', labelKey: 'scope_payroll_write', tools: 3 },
|
||||
},
|
||||
{
|
||||
domain: 'pending_operations',
|
||||
labelKey: 'group_pending_operations',
|
||||
read: { scope: 'pending_operations:read', labelKey: 'scope_pending_operations_read', tools: 1 },
|
||||
write: { scope: 'pending_operations:approve', labelKey: 'scope_pending_operations_approve', tools: 2 },
|
||||
},
|
||||
{
|
||||
domain: 'agent',
|
||||
labelKey: 'group_agent',
|
||||
read: { scope: 'agent:read', labelKey: 'scope_agent_read', tools: 1 },
|
||||
write: { scope: 'agent:write', labelKey: 'scope_agent_write', tools: 2 },
|
||||
},
|
||||
{
|
||||
domain: 'documents',
|
||||
labelKey: 'group_documents',
|
||||
read: { scope: 'documents:read', labelKey: 'scope_documents_read', tools: 0 },
|
||||
write: { scope: 'documents:write', labelKey: 'scope_documents_write', tools: 0 },
|
||||
},
|
||||
{
|
||||
domain: 'companies',
|
||||
labelKey: 'group_companies',
|
||||
read: { scope: 'companies:read', labelKey: 'scope_companies_read', tools: 1 },
|
||||
write: null,
|
||||
},
|
||||
{
|
||||
domain: 'events',
|
||||
labelKey: 'group_events',
|
||||
read: { scope: 'events:read', labelKey: 'scope_events_read', tools: 0 },
|
||||
write: null,
|
||||
},
|
||||
{
|
||||
domain: 'webhooks',
|
||||
labelKey: 'group_webhooks',
|
||||
read: null,
|
||||
write: { scope: 'webhooks:manage', labelKey: 'scope_webhooks_manage', tools: 0 },
|
||||
},
|
||||
{
|
||||
domain: 'operations',
|
||||
labelKey: 'group_operations',
|
||||
read: { scope: 'operations:read', labelKey: 'scope_operations_read', tools: 0 },
|
||||
write: null,
|
||||
},
|
||||
{
|
||||
domain: 'compliance',
|
||||
labelKey: 'group_compliance',
|
||||
read: { scope: 'compliance:read', labelKey: 'scope_compliance_read', tools: 3 },
|
||||
write: null,
|
||||
},
|
||||
{
|
||||
domain: 'skatteverket',
|
||||
labelKey: 'group_skatteverket',
|
||||
read: null,
|
||||
write: { scope: 'skatteverket:write', labelKey: 'scope_skatteverket_write', tools: 2 },
|
||||
},
|
||||
]
|
||||
|
||||
type Scope = ApiKeyScope
|
||||
|
||||
const ALL_SCOPES: Scope[] = SCOPE_GROUPS.flatMap((g) => {
|
||||
const out: Scope[] = []
|
||||
if (g.read) out.push(g.read.scope)
|
||||
if (g.write) out.push(g.write.scope)
|
||||
return out
|
||||
})
|
||||
/** i18n key for a scope card: `scope_<domain>_<verb>`. */
|
||||
const scopeLabelKey = (scope: Scope) => `scope_${scope.replace(':', '_')}`
|
||||
/** i18n key for a group heading: `group_<domain>`. */
|
||||
const groupLabelKey = (group: ScopeGroup) => `group_${group.domain}`
|
||||
/** A group with no MCP tool behind any of its scopes only gates REST endpoints. */
|
||||
const isRestOnlyGroup = (group: ScopeGroup) =>
|
||||
group.scopes.every((scope) => TOOL_COUNT_BY_SCOPE[scope] === 0)
|
||||
|
||||
interface ApiKey {
|
||||
id: string
|
||||
@@ -223,16 +118,17 @@ function CopyBlock({ text, copyAriaLabel }: { text: string; copyAriaLabel: strin
|
||||
}
|
||||
|
||||
function ScopeCard({
|
||||
entry,
|
||||
scope,
|
||||
checked,
|
||||
onCheckedChange,
|
||||
}: {
|
||||
entry: ScopeEntry
|
||||
scope: Scope
|
||||
checked: boolean
|
||||
onCheckedChange: (checked: boolean) => void
|
||||
}) {
|
||||
const t = useTranslations('settings_api_keys')
|
||||
const label = t(entry.labelKey)
|
||||
const label = t(scopeLabelKey(scope))
|
||||
const tools = TOOL_COUNT_BY_SCOPE[scope]
|
||||
const sepIdx = label.indexOf(': ')
|
||||
const verb = sepIdx > 0 ? label.slice(0, sepIdx) : label
|
||||
const description = sepIdx > 0 ? label.slice(sepIdx + 2) : ''
|
||||
@@ -254,7 +150,7 @@ function ScopeCard({
|
||||
/>
|
||||
<span className="flex-1 text-xs font-medium text-foreground">{verb}</span>
|
||||
<span className="shrink-0 text-[10px] tabular-nums text-muted-foreground">
|
||||
{entry.tools > 0 ? t('tools_count', { count: entry.tools }) : t('rest_badge')}
|
||||
{tools > 0 ? t('tools_count', { count: tools }) : t('rest_badge')}
|
||||
</span>
|
||||
</div>
|
||||
{description && (
|
||||
@@ -297,6 +193,24 @@ export function ApiKeysPanel() {
|
||||
const hasSodConflict =
|
||||
newKeyScopes.has('pending_operations:approve') && sodConflictScope !== null
|
||||
|
||||
// Elevated scopes (write/approve/signoff) imply the group's read scope:
|
||||
// ticking one ticks read, and unticking read clears the whole group.
|
||||
function toggleScope(group: ScopeGroup, scope: Scope, checked: boolean) {
|
||||
setNewKeyScopes((prev) => {
|
||||
const next = new Set(prev)
|
||||
const readScope = group.scopes.find((s) => scopeKind(s) === 'read')
|
||||
if (checked) {
|
||||
next.add(scope)
|
||||
if (readScope) next.add(readScope)
|
||||
} else if (scope === readScope) {
|
||||
for (const s of group.scopes) next.delete(s)
|
||||
} else {
|
||||
next.delete(scope)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const fetchKeys = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/settings/api-keys')
|
||||
@@ -638,44 +552,20 @@ export function ApiKeysPanel() {
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{SCOPE_GROUPS.map((group) => (
|
||||
<div key={group.domain} className="space-y-2">
|
||||
<h4 className="text-sm font-medium">{t(group.labelKey)}</h4>
|
||||
<h4 className="text-sm font-medium">
|
||||
{isRestOnlyGroup(group)
|
||||
? t('group_rest_only', { name: t(groupLabelKey(group)) })
|
||||
: t(groupLabelKey(group))}
|
||||
</h4>
|
||||
<div className="space-y-2 px-2">
|
||||
{group.read && (
|
||||
{group.scopes.map((scope) => (
|
||||
<ScopeCard
|
||||
entry={group.read}
|
||||
checked={newKeyScopes.has(group.read.scope)}
|
||||
onCheckedChange={(checked) => {
|
||||
setNewKeyScopes((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (checked) {
|
||||
next.add(group.read!.scope)
|
||||
} else {
|
||||
next.delete(group.read!.scope)
|
||||
if (group.write) next.delete(group.write.scope)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}}
|
||||
key={scope}
|
||||
scope={scope}
|
||||
checked={newKeyScopes.has(scope)}
|
||||
onCheckedChange={(checked) => toggleScope(group, scope, checked)}
|
||||
/>
|
||||
)}
|
||||
{group.write && (
|
||||
<ScopeCard
|
||||
entry={group.write}
|
||||
checked={newKeyScopes.has(group.write.scope)}
|
||||
onCheckedChange={(checked) => {
|
||||
setNewKeyScopes((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (checked) {
|
||||
next.add(group.write!.scope)
|
||||
if (group.read) next.add(group.read.scope)
|
||||
} else {
|
||||
next.delete(group.write!.scope)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
ALL_SCOPES,
|
||||
API_KEY_SCOPES,
|
||||
SCOPE_GROUPS,
|
||||
TOOL_COUNT_BY_SCOPE,
|
||||
TOOL_SCOPE_MAP,
|
||||
scopeKind,
|
||||
type ApiKeyScope,
|
||||
} from '../scope-catalog'
|
||||
import * as apiKeys from '../api-keys'
|
||||
|
||||
describe('SCOPE_GROUPS', () => {
|
||||
it('covers every scope in API_KEY_SCOPES exactly once', () => {
|
||||
const occurrences = new Map<ApiKeyScope, number>()
|
||||
for (const group of SCOPE_GROUPS) {
|
||||
for (const scope of group.scopes) {
|
||||
occurrences.set(scope, (occurrences.get(scope) ?? 0) + 1)
|
||||
}
|
||||
}
|
||||
const missing = ALL_SCOPES.filter((s) => !occurrences.has(s))
|
||||
const duplicated = [...occurrences].filter(([, n]) => n > 1).map(([s]) => s)
|
||||
expect(missing).toEqual([])
|
||||
expect(duplicated).toEqual([])
|
||||
})
|
||||
|
||||
it('only references scopes that exist in the catalogue', () => {
|
||||
for (const group of SCOPE_GROUPS) {
|
||||
for (const scope of group.scopes) {
|
||||
expect(scope in API_KEY_SCOPES).toBe(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('has unique domains and lists the read scope first', () => {
|
||||
const domains = SCOPE_GROUPS.map((g) => g.domain)
|
||||
expect(new Set(domains).size).toBe(domains.length)
|
||||
for (const group of SCOPE_GROUPS) {
|
||||
const readIndex = group.scopes.findIndex((s) => scopeKind(s) === 'read')
|
||||
if (readIndex !== -1) expect(readIndex).toBe(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('TOOL_COUNT_BY_SCOPE', () => {
|
||||
it('has an entry for every scope and none for anything else', () => {
|
||||
expect(Object.keys(TOOL_COUNT_BY_SCOPE).sort()).toEqual([...ALL_SCOPES].sort())
|
||||
})
|
||||
|
||||
it('equals the number of TOOL_SCOPE_MAP entries mapped to each scope', () => {
|
||||
for (const scope of ALL_SCOPES) {
|
||||
const expected = Object.values(TOOL_SCOPE_MAP).filter((s) => s === scope).length
|
||||
expect(TOOL_COUNT_BY_SCOPE[scope], scope).toBe(expected)
|
||||
}
|
||||
const total = Object.values(TOOL_COUNT_BY_SCOPE).reduce((a, b) => a + b, 0)
|
||||
expect(total).toBe(Object.keys(TOOL_SCOPE_MAP).length)
|
||||
})
|
||||
|
||||
it('only maps tools to scopes that exist', () => {
|
||||
for (const [tool, scope] of Object.entries(TOOL_SCOPE_MAP)) {
|
||||
expect(scope in API_KEY_SCOPES, tool).toBe(true)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('API_KEY_SCOPES labels', () => {
|
||||
it('carries no hand-written tool counts (they are derived)', () => {
|
||||
for (const [scope, meta] of Object.entries(API_KEY_SCOPES)) {
|
||||
expect(meta.description, scope).not.toMatch(/\(\d+ verktyg\)/)
|
||||
expect(meta.label, scope).not.toMatch(/\(\d+ verktyg\)/)
|
||||
}
|
||||
})
|
||||
|
||||
it('formats every label as "Område: verb"', () => {
|
||||
for (const meta of Object.values(API_KEY_SCOPES)) {
|
||||
expect(meta.label).toMatch(/^[^:]+: .+$/)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('scopeKind', () => {
|
||||
it('treats :read as read and everything else as an elevated grant', () => {
|
||||
expect(scopeKind('transactions:read')).toBe('read')
|
||||
expect(scopeKind('transactions:write')).toBe('write')
|
||||
expect(scopeKind('webhooks:manage')).toBe('write')
|
||||
expect(scopeKind('pending_operations:approve')).toBe('write')
|
||||
expect(scopeKind('reconciliation:signoff')).toBe('write')
|
||||
})
|
||||
})
|
||||
|
||||
describe('api-keys re-exports', () => {
|
||||
it('exposes the same catalogue objects so server imports keep working', () => {
|
||||
expect(apiKeys.API_KEY_SCOPES).toBe(API_KEY_SCOPES)
|
||||
expect(apiKeys.SCOPE_GROUPS).toBe(SCOPE_GROUPS)
|
||||
expect(apiKeys.TOOL_SCOPE_MAP).toBe(TOOL_SCOPE_MAP)
|
||||
expect(apiKeys.TOOL_COUNT_BY_SCOPE).toBe(TOOL_COUNT_BY_SCOPE)
|
||||
expect(apiKeys.ALL_SCOPES).toBe(ALL_SCOPES)
|
||||
})
|
||||
})
|
||||
+17
-370
@@ -10,376 +10,23 @@ const KEY_PREFIX = 'gnubok_sk_'
|
||||
const REFRESH_TOKEN_PREFIX = 'gnubok_rt_'
|
||||
|
||||
// ── API Key Scopes ──────────────────────────────────────────
|
||||
|
||||
export const API_KEY_SCOPES = {
|
||||
'transactions:read': { label: 'Transaktioner: läs', description: 'Lista transaktioner, mallförslag, kategoriförslag (3 verktyg)' },
|
||||
'transactions:write': { label: 'Transaktioner: skriv', description: 'Kategorisera, av-kategorisera, kvittomatchning, koppling mot faktura (4 verktyg)' },
|
||||
'customers:read': { label: 'Kunder: läs', description: 'Lista kunder (1 verktyg)' },
|
||||
'customers:write': { label: 'Kunder: skriv', description: 'Skapa och uppdatera kunder (2 verktyg)' },
|
||||
'articles:read': { label: 'Artiklar: läs', description: 'Lista artiklar i artikelregistret (1 verktyg)' },
|
||||
'articles:write': { label: 'Artiklar: skriv', description: 'Skapa och uppdatera artiklar (2 verktyg)' },
|
||||
'invoices:read': { label: 'Fakturor: läs', description: 'Lista fakturor (1 verktyg)' },
|
||||
'invoices:write': { label: 'Fakturor: skriv', description: 'Skapa, skicka, markera betald/skickad (4 verktyg)' },
|
||||
'suppliers:read': { label: 'Leverantörer: läs', description: 'Lista leverantörer och leverantörsfakturor, hitta verifikat-kandidater (3 verktyg)' },
|
||||
'suppliers:write': { label: 'Leverantörer: skriv', description: 'Skapa leverantörer; godkänn, kreditera, betal-länka och hantera leverantörsfakturor (6 verktyg)' },
|
||||
'reports:read': { label: 'Rapporter: läs', description: 'Kontoplan, huvudbok, balansräkning, resultaträkning, moms, KPI, reskontra, perioder, bankavstämning, SIE-export (12 verktyg)' },
|
||||
'bookkeeping:write': { label: 'Bokföring: skriv', description: 'Stänga/låsa perioder, ingående balans, bokslut, SIE-import, voucher-gap-förklaringar, kontoplan (skapa/ändra konton), verifikat-anteckningar' },
|
||||
'payroll:read': { label: 'Löner: läs', description: 'Lista anställda, lönekörningar, lönejournal, körjournal' },
|
||||
'payroll:write': { label: 'Löner: skriv', description: 'Skapa lönekörning, beräkna, generera AGI, logga körjournalresor' },
|
||||
// v1 REST API: added Phase 1
|
||||
'companies:read': { label: 'Företag: läs', description: 'Lista och visa företagsprofiler som API-nyckeln har tillgång till' },
|
||||
'companies:write': { label: 'Företag: skriv', description: 'Skapa nya företag och uppdatera företagsinställningar (gnubok_create_company, stagade verktyg, REST POST /api/v1/companies och PATCH /api/v1/companies/{companyId}/settings)' },
|
||||
'events:read': { label: 'Händelser: läs', description: 'Polla händelseloggen (event_log) som webhook-fallback' },
|
||||
'webhooks:manage': { label: 'Webhooks: hantera', description: 'Skapa, lista, uppdatera och radera webhook-prenumerationer' },
|
||||
'operations:read': { label: 'Operationer: läs', description: 'Hämta status för långkörande operationer (importer, bokslut, omvärdering)' },
|
||||
'documents:read': { label: 'Dokument: läs', description: 'Lista och hämta dokumentbilagor' },
|
||||
'documents:write': { label: 'Dokument: skriv', description: 'Ladda upp och koppla dokument till verifikationer' },
|
||||
'compliance:read': { label: 'Compliance: läs', description: 'Pre-flight-kontroller: momsstängning, bokslutsberedskap, voucher-gap, IB/UB-kontinuitet; Skatteverket-status (moms + AGI)' },
|
||||
'skatteverket:write': { label: 'Skatteverket: skriv', description: 'Lämna momsdeklaration och arbetsgivardeklaration (AGI) till Skatteverket (stagas; signeras med BankID)' },
|
||||
'agent:read': { label: 'Agent: läs', description: 'Specialiserad bokföringsassistent: profil, laddade specialister/atomer, minnen (briefing + skill-katalog)' },
|
||||
'agent:write': { label: 'Agent: skriv', description: 'Spara och ta bort agentens minnen om företaget (remember_fact, forget_fact)' },
|
||||
'pending_operations:read': { label: 'Stagade operationer: läs', description: 'Lista pending_operations (staged writes awaiting approval)' },
|
||||
'pending_operations:approve': { label: 'Stagade operationer: godkänn', description: 'Godkänn eller avvisa stagade operationer via API/MCP: agenten ersätter web-UI:s granskning' },
|
||||
// Reconciliation (account-keyed: bank accounts + skattekonto). Reads cover
|
||||
// the account list, the bridge and the item buckets; writes cover links
|
||||
// (match/unmatch) and ignore flags. Links never touch the ledger.
|
||||
'reconciliation:read': { label: 'Avstämning: läs', description: 'Konton att stämma av, bryggan per konto och raderna bakom den (bank + skattekonto)' },
|
||||
'reconciliation:write': { label: 'Avstämning: skriv', description: 'Koppla och koppla bort händelser mot verifikat, ignorera rader (MCP stagar; REST skriver direkt)' },
|
||||
'reconciliation:signoff': { label: 'Avstämning: signera', description: 'Markera ett konto som avstämt t.o.m. ett datum och öppna en signering igen (MCP stagar; REST skriver direkt)' },
|
||||
} as const
|
||||
|
||||
export type ApiKeyScope = keyof typeof API_KEY_SCOPES
|
||||
|
||||
export const ALL_SCOPES: ApiKeyScope[] = Object.keys(API_KEY_SCOPES) as ApiKeyScope[]
|
||||
|
||||
/** The read-only scopes assigned to keys with no explicit scopes (legacy/null). */
|
||||
export const DEFAULT_SCOPES: ApiKeyScope[] = [
|
||||
'transactions:read',
|
||||
'customers:read',
|
||||
'articles:read',
|
||||
'invoices:read',
|
||||
'suppliers:read',
|
||||
'reports:read',
|
||||
]
|
||||
|
||||
/**
|
||||
* Default scope grant for OAuth-issued keys when the client did not pass an
|
||||
* explicit `scope` parameter at /authorize. Read-only by design: every
|
||||
* write or approval scope must be requested explicitly by the client AND
|
||||
* affirmatively ticked by the user on the consent screen.
|
||||
*
|
||||
* Rationale (do not weaken without a documented security decision):
|
||||
* - GDPR Art. 25(2) data-protection-by-default: the minimum-necessary
|
||||
* access set must be the silent baseline.
|
||||
* - ISO 27001:2022 A.5.18 / A.8.2 / SOC 2 CC6.3: privileged capabilities
|
||||
* (write, approve) must not be bundled into a default grant.
|
||||
* - Segregation of Duties (findStageApproveConflict below): granting any
|
||||
* STAGING_SCOPES member together with `pending_operations:approve` on a
|
||||
* single key lets an automated agent both stage AND commit financial
|
||||
* postings without a human-in-the-loop review. Keeping the default
|
||||
* read-only prevents this combination from being silently issued.
|
||||
* - BFL 5 kap 5§ / BFNAR 2013:2 behandlingshistorik: write paths that
|
||||
* create or modify verifikationer must be opt-in at the authorization
|
||||
* layer; conversational acknowledgement at the agent layer is not an
|
||||
* auditable substitute.
|
||||
*/
|
||||
export const DEFAULT_OAUTH_SCOPES: ApiKeyScope[] = [
|
||||
'transactions:read',
|
||||
'customers:read',
|
||||
'articles:read',
|
||||
'invoices:read',
|
||||
'suppliers:read',
|
||||
'reports:read',
|
||||
'companies:read',
|
||||
'events:read',
|
||||
'operations:read',
|
||||
'documents:read',
|
||||
'compliance:read',
|
||||
'payroll:read',
|
||||
'pending_operations:read',
|
||||
]
|
||||
|
||||
/**
|
||||
* Scopes advertised in the RFC 8414 authorization-server metadata document
|
||||
* (/.well-known/oauth-authorization-server). Restricted to the same set that
|
||||
* /authorize will grant by default: destructive scopes still work when
|
||||
* requested explicitly, they just aren't enumerated for unauthenticated
|
||||
* callers (defense-in-depth against scope-escalation reconnaissance).
|
||||
*/
|
||||
export const PUBLIC_OAUTH_METADATA_SCOPES: ApiKeyScope[] = [...DEFAULT_OAUTH_SCOPES]
|
||||
|
||||
/**
|
||||
* Scopes that allow staging a pending_operation. Used to detect a
|
||||
* segregation-of-duties conflict when paired with `pending_operations:approve`
|
||||
* on the same API key (ISO 27001:2022 A.5.3, SOC 2 CC6.1).
|
||||
*
|
||||
* Documented system control (BFNAR 2013:2 systemdokumentation): `agent:write`
|
||||
* is deliberately NOT a staging scope. The memory tools it gates
|
||||
* (gnubok_remember_fact/forget_fact) write advisory agent context: they
|
||||
* cannot create, mutate, or stage räkenskapsinformation, so memory-write +
|
||||
* approve on one key does not let an agent both stage and commit bookkeeping.
|
||||
* If a future memory surface ever feeds DIRECTLY into voucher generation
|
||||
* (rather than via a separately staged-and-approved operation), revisit this
|
||||
* classification.
|
||||
*/
|
||||
export const STAGING_SCOPES: ApiKeyScope[] = [
|
||||
'transactions:write',
|
||||
'customers:write',
|
||||
'articles:write',
|
||||
'invoices:write',
|
||||
'suppliers:write',
|
||||
'bookkeeping:write',
|
||||
'payroll:write',
|
||||
'documents:write',
|
||||
'companies:write',
|
||||
// Skatteverket submit tools stage submit_vat_declaration / submit_agi, so a
|
||||
// key holding both this and pending_operations:approve is a SoD conflict:
|
||||
// findStageApproveConflict picks it up automatically from this list.
|
||||
'skatteverket:write',
|
||||
// gnubok_reconcile_match / gnubok_reconcile_unmatch stage reconciliation_*
|
||||
// operations; same SoD reasoning.
|
||||
'reconciliation:write',
|
||||
// gnubok_reconcile_signoff stages reconciliation_signoff.
|
||||
'reconciliation:signoff',
|
||||
]
|
||||
|
||||
/**
|
||||
* Detect a segregation-of-duties conflict between staging and approval scopes
|
||||
* on the same key. Returns the offending staging scope, or null when the
|
||||
* combination is clean. Callers may choose to block, warn, or record an
|
||||
* acknowledged risk acceptance.
|
||||
*
|
||||
* Granting both stage+approve to the same actor lets an automated agent both
|
||||
* stage AND commit financial postings without a human-in-the-loop review,
|
||||
* which is the explicit control surface for BFNAR 2013:2 (behandlingshistorik)
|
||||
* and BFL 5 kap 5§ traceability requirements.
|
||||
*/
|
||||
export function findStageApproveConflict(scopes: ApiKeyScope[]): ApiKeyScope | null {
|
||||
if (!scopes.includes('pending_operations:approve')) return null
|
||||
return scopes.find((s) => STAGING_SCOPES.includes(s)) ?? null
|
||||
}
|
||||
|
||||
/** Scope domain groups for UI rendering */
|
||||
export const SCOPE_GROUPS = [
|
||||
{ domain: 'companies', label: 'Företag', read: 'companies:read' as const, write: 'companies:write' as const },
|
||||
{ domain: 'transactions', label: 'Transaktioner', read: 'transactions:read' as const, write: 'transactions:write' as const },
|
||||
{ domain: 'customers', label: 'Kunder', read: 'customers:read' as const, write: 'customers:write' as const },
|
||||
{ domain: 'articles', label: 'Artiklar', read: 'articles:read' as const, write: 'articles:write' as const },
|
||||
{ domain: 'invoices', label: 'Fakturor', read: 'invoices:read' as const, write: 'invoices:write' as const },
|
||||
{ domain: 'suppliers', label: 'Leverantörer', read: 'suppliers:read' as const, write: 'suppliers:write' as const },
|
||||
{ domain: 'reports', label: 'Rapporter', read: 'reports:read' as const, write: null },
|
||||
{ domain: 'bookkeeping', label: 'Bokföring', read: null, write: 'bookkeeping:write' as const },
|
||||
{ domain: 'payroll', label: 'Löner', read: 'payroll:read' as const, write: 'payroll:write' as const },
|
||||
{ domain: 'pending_operations', label: 'Stagade operationer', read: 'pending_operations:read' as const, write: 'pending_operations:approve' as const },
|
||||
{ domain: 'agent', label: 'Agent', read: 'agent:read' as const, write: 'agent:write' as const },
|
||||
{ domain: 'skatteverket', label: 'Skatteverket', read: null, write: 'skatteverket:write' as const },
|
||||
] as const
|
||||
|
||||
/** Map MCP tool name → required scope. Tools omitted from this map are available to any authenticated key (e.g. discovery/search/skill loading). */
|
||||
export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
|
||||
// Companies
|
||||
gnubok_list_companies: 'companies:read',
|
||||
gnubok_lookup_company: 'companies:read',
|
||||
gnubok_create_company: 'companies:write',
|
||||
gnubok_connect_bank: 'companies:read',
|
||||
gnubok_connect_skatteverket: 'companies:read',
|
||||
gnubok_get_company_settings: 'companies:read',
|
||||
gnubok_update_company_settings: 'companies:write',
|
||||
// Transactions
|
||||
gnubok_list_uncategorized_transactions: 'transactions:read',
|
||||
gnubok_list_cash_accounts: 'transactions:read',
|
||||
gnubok_list_transactions_without_documents: 'transactions:read',
|
||||
gnubok_create_transactions: 'transactions:write',
|
||||
gnubok_categorize_transaction: 'transactions:write',
|
||||
gnubok_receipt_matcher: 'transactions:write',
|
||||
gnubok_get_counterparty_templates: 'transactions:read',
|
||||
gnubok_suggest_categories: 'transactions:read',
|
||||
gnubok_match_transaction_to_invoice: 'transactions:write',
|
||||
gnubok_link_transaction_to_journal_entry: 'transactions:write',
|
||||
gnubok_match_batch_allocate: 'transactions:write',
|
||||
// Reconciliation (account-keyed). gnubok_get_reconciliation_status keeps its
|
||||
// historical reports:read so existing keys are not cut off.
|
||||
gnubok_list_reconciliation_items: 'reconciliation:read',
|
||||
gnubok_reconcile_match: 'reconciliation:write',
|
||||
gnubok_reconcile_unmatch: 'reconciliation:write',
|
||||
gnubok_reconcile_signoff: 'reconciliation:signoff',
|
||||
// Residual booking writes a verifikat: the same scope that books a bank row.
|
||||
gnubok_reconcile_residual: 'transactions:write',
|
||||
gnubok_bulk_book_transactions: 'transactions:write',
|
||||
gnubok_bulk_book_inbox_items: 'transactions:write',
|
||||
gnubok_auto_match_period: 'transactions:write',
|
||||
// Customers
|
||||
gnubok_list_customers: 'customers:read',
|
||||
gnubok_create_customer: 'customers:write',
|
||||
gnubok_update_customer: 'customers:write',
|
||||
// Articles (artikelregister)
|
||||
gnubok_list_articles: 'articles:read',
|
||||
gnubok_create_article: 'articles:write',
|
||||
gnubok_update_article: 'articles:write',
|
||||
// Invoices
|
||||
gnubok_list_invoices: 'invoices:read',
|
||||
gnubok_get_invoice_deliveries: 'invoices:read',
|
||||
gnubok_create_invoice: 'invoices:write',
|
||||
gnubok_update_invoice: 'invoices:write',
|
||||
gnubok_send_invoice: 'invoices:write',
|
||||
gnubok_mark_invoice_as_paid: 'invoices:write',
|
||||
gnubok_mark_invoice_as_sent: 'invoices:write',
|
||||
// Recurring invoice schedules (staged template writes; no send/book at commit)
|
||||
gnubok_list_recurring_schedules: 'invoices:read',
|
||||
gnubok_create_recurring_schedule: 'invoices:write',
|
||||
gnubok_update_recurring_schedule: 'invoices:write',
|
||||
// Suppliers
|
||||
gnubok_list_suppliers: 'suppliers:read',
|
||||
gnubok_list_supplier_invoices: 'suppliers:read',
|
||||
// Reports
|
||||
gnubok_get_trial_balance: 'reports:read',
|
||||
gnubok_get_vat_report: 'reports:read',
|
||||
gnubok_vat_review_widget: 'reports:read',
|
||||
gnubok_vat_close_check: 'reports:read',
|
||||
gnubok_get_kpi_report: 'reports:read',
|
||||
gnubok_get_income_statement: 'reports:read',
|
||||
gnubok_list_accounts: 'reports:read',
|
||||
// Kontoplan management: staged reference-data writes
|
||||
gnubok_create_account: 'bookkeeping:write',
|
||||
gnubok_update_account: 'bookkeeping:write',
|
||||
// Verifikat annotation (notes-only edit: allowed on posted entries)
|
||||
gnubok_set_voucher_note: 'bookkeeping:write',
|
||||
gnubok_get_balance_sheet: 'reports:read',
|
||||
gnubok_get_general_ledger: 'reports:read',
|
||||
gnubok_query_journal: 'reports:read',
|
||||
gnubok_get_ar_ledger: 'reports:read',
|
||||
gnubok_get_supplier_ledger: 'reports:read',
|
||||
gnubok_list_fiscal_periods: 'reports:read',
|
||||
gnubok_get_reconciliation_status: 'reports:read',
|
||||
gnubok_list_accrual_schedules: 'reports:read',
|
||||
// Dimensions (kostnadsställe/projekt) registry: reads next to the report
|
||||
// tools; the staged value-create is a bookkeeping write (dimensions PR3).
|
||||
gnubok_list_dimensions: 'reports:read',
|
||||
gnubok_list_dimension_values: 'reports:read',
|
||||
gnubok_create_dimension_value: 'bookkeeping:write',
|
||||
gnubok_get_dimension_pnl: 'reports:read',
|
||||
// Staged bulk retag of posted-line dimensions (dimensions PR6).
|
||||
gnubok_tag_journal_lines: 'bookkeeping:write',
|
||||
// Document inbox
|
||||
gnubok_create_document_upload: 'transactions:write',
|
||||
gnubok_complete_document_upload: 'transactions:write',
|
||||
gnubok_upload_document: 'transactions:write',
|
||||
gnubok_list_inbox_items: 'transactions:read',
|
||||
gnubok_get_inbox_item: 'transactions:read',
|
||||
gnubok_list_unmatched_documents: 'transactions:read',
|
||||
gnubok_get_document_content: 'transactions:read',
|
||||
gnubok_attach_document_to_transaction: 'transactions:write',
|
||||
gnubok_link_document_to_voucher: 'bookkeeping:write',
|
||||
gnubok_link_documents_to_vouchers: 'bookkeeping:write',
|
||||
// Körjournal (mileage): trip log reads/writes are payroll surface
|
||||
// (milersättning, 7331); booking the verifikat is a journal write.
|
||||
gnubok_list_mileage_trips: 'payroll:read',
|
||||
gnubok_log_mileage_trip: 'payroll:write',
|
||||
gnubok_book_mileage_period: 'bookkeeping:write',
|
||||
// Payroll
|
||||
gnubok_list_employees: 'payroll:read',
|
||||
gnubok_get_salary_run: 'payroll:read',
|
||||
gnubok_get_salary_journal: 'payroll:read',
|
||||
gnubok_create_salary_run: 'payroll:write',
|
||||
gnubok_calculate_salary_run: 'payroll:write',
|
||||
gnubok_book_salary_run: 'payroll:write',
|
||||
gnubok_generate_agi: 'payroll:write',
|
||||
// Payroll gap-closure: reads + staged writes (1.6-1.8, 2.4)
|
||||
gnubok_get_employee: 'payroll:read',
|
||||
gnubok_get_payslip: 'payroll:read',
|
||||
gnubok_list_absence: 'payroll:read',
|
||||
gnubok_update_payslip_line: 'payroll:write',
|
||||
gnubok_register_absence: 'payroll:write',
|
||||
gnubok_delete_absence: 'payroll:write',
|
||||
gnubok_create_employee: 'payroll:write',
|
||||
gnubok_update_employee: 'payroll:write',
|
||||
gnubok_set_employee_opening_balances: 'payroll:write',
|
||||
gnubok_get_vacation_balance: 'payroll:read',
|
||||
gnubok_close_vacation_year: 'payroll:write',
|
||||
// Bookkeeping write (Stream 1 Phase 1): high-risk, always staged
|
||||
gnubok_close_period: 'bookkeeping:write',
|
||||
gnubok_lock_period: 'bookkeeping:write',
|
||||
gnubok_unlock_period: 'bookkeeping:write',
|
||||
gnubok_run_year_end: 'bookkeeping:write',
|
||||
gnubok_post_kontantmetod_cutoff: 'bookkeeping:write',
|
||||
gnubok_year_end_readiness: 'reports:read',
|
||||
gnubok_set_opening_balances: 'bookkeeping:write',
|
||||
gnubok_run_currency_revaluation: 'bookkeeping:write',
|
||||
gnubok_explain_voucher_gap: 'bookkeeping:write',
|
||||
gnubok_list_voucher_gaps: 'reports:read',
|
||||
// Transaction reversal (medium-risk)
|
||||
gnubok_uncategorize_transaction: 'transactions:write',
|
||||
// SIE export (read-only) + import (write)
|
||||
gnubok_export_sie: 'reports:read',
|
||||
gnubok_audit_package: 'reports:read',
|
||||
gnubok_import_sie: 'bookkeeping:write',
|
||||
// Rot/rut begäran om utbetalning (records a payout request on generate)
|
||||
gnubok_generate_rot_rut_file: 'invoices:write',
|
||||
// Supplier CRUD
|
||||
gnubok_create_supplier: 'suppliers:write',
|
||||
// Supplier invoice lifecycle
|
||||
gnubok_approve_supplier_invoice: 'suppliers:write',
|
||||
gnubok_credit_supplier_invoice: 'suppliers:write',
|
||||
gnubok_create_supplier_invoice_from_inbox: 'suppliers:write',
|
||||
gnubok_set_inbox_extracted_data: 'suppliers:write',
|
||||
// Supplier invoice payment via existing verifikat (no new bokföring)
|
||||
gnubok_find_voucher_candidates_for_supplier_invoice: 'suppliers:read',
|
||||
gnubok_link_supplier_invoice_to_voucher: 'suppliers:write',
|
||||
// Invoice conversion + crediting
|
||||
gnubok_convert_invoice: 'invoices:write',
|
||||
gnubok_credit_invoice: 'invoices:write',
|
||||
// Phase 4: arbitrary-line bookkeeping primitives (high-risk, always staged)
|
||||
gnubok_create_voucher: 'bookkeeping:write',
|
||||
gnubok_correct_entry: 'bookkeeping:write',
|
||||
gnubok_reverse_journal_entry: 'bookkeeping:write',
|
||||
// Agent surface (Phase 6 MCP parity): briefing tool exposes company-specific
|
||||
// profile + memory so it's scoped; gnubok_list_skills / gnubok_load_skill
|
||||
// stay unscoped (discovery + static Markdown bodies + globally-readable atom
|
||||
// registry: no per-company data).
|
||||
gnubok_get_agent_briefing: 'agent:read',
|
||||
// Agent memory write (previously UNMAPPED → callable by any key). Mapping to
|
||||
// agent:write; existing non-revoked keys are grandfathered in the
|
||||
// 20260619140000 migration so this does not regress them.
|
||||
gnubok_remember_fact: 'agent:write',
|
||||
gnubok_forget_fact: 'agent:write',
|
||||
// Pending operations approval (mirrors the /pending web UI)
|
||||
gnubok_list_pending_operations: 'pending_operations:read',
|
||||
gnubok_approve_pending_operation: 'pending_operations:approve',
|
||||
gnubok_reject_pending_operation: 'pending_operations:approve',
|
||||
// Skatteverket filing (PR5). Reads are compliance:read (status of moms/AGI);
|
||||
// the two submit tools require the opt-in skatteverket:write staging scope.
|
||||
gnubok_vat_declaration_validate: 'compliance:read',
|
||||
gnubok_vat_declaration_status: 'compliance:read',
|
||||
gnubok_agi_status: 'compliance:read',
|
||||
gnubok_vat_declaration_submit: 'skatteverket:write',
|
||||
gnubok_agi_submit: 'skatteverket:write',
|
||||
|
||||
// ── Audit retrofit (agent-native audit P0: unmapped = default-allow) ──
|
||||
// These tools shipped without a scope mapping, making them callable by ANY
|
||||
// authenticated key. Mapping them is accept-the-break by decision
|
||||
// (2026-07-13): keys that relied on the default-allow hole lose access
|
||||
// until granted the proper scope. Release-note callout required for the
|
||||
// four WRITES below.
|
||||
gnubok_link_invoice_to_voucher: 'invoices:write',
|
||||
gnubok_undo_sie_import: 'bookkeeping:write',
|
||||
gnubok_post_annual_depreciation: 'bookkeeping:write',
|
||||
gnubok_import_rot_rut_beslut: 'invoices:write',
|
||||
gnubok_list_verifikat_without_documents: 'transactions:read',
|
||||
gnubok_find_voucher_candidates_for_invoice: 'invoices:read',
|
||||
gnubok_propose_dispositioner: 'reports:read',
|
||||
gnubok_propose_accruals: 'reports:read',
|
||||
gnubok_propose_annual_depreciation: 'reports:read',
|
||||
gnubok_preview_arsredovisning: 'reports:read',
|
||||
gnubok_validate_arsredovisning: 'reports:read',
|
||||
gnubok_list_arsredovisning_versions: 'reports:read',
|
||||
gnubok_get_arsredovisning_filing_status: 'reports:read',
|
||||
gnubok_preview_ef_declaration: 'reports:read',
|
||||
// Deliberately UNSCOPED (available to any authenticated key):
|
||||
// gnubok_search_tools, gnubok_list_skills, gnubok_load_skill,
|
||||
// gnubok_feedback. Discovery + static skill bodies + feedback channel
|
||||
// carry no per-company data; keeping them open is what lets an agent
|
||||
// orient itself before its key's scopes are known.
|
||||
}
|
||||
// The catalogue lives in scope-catalog.ts (no server imports, safe for client
|
||||
// bundles) and is re-exported here so existing imports keep working.
|
||||
export {
|
||||
API_KEY_SCOPES,
|
||||
ALL_SCOPES,
|
||||
DEFAULT_SCOPES,
|
||||
DEFAULT_OAUTH_SCOPES,
|
||||
PUBLIC_OAUTH_METADATA_SCOPES,
|
||||
STAGING_SCOPES,
|
||||
findStageApproveConflict,
|
||||
SCOPE_GROUPS,
|
||||
scopeKind,
|
||||
TOOL_SCOPE_MAP,
|
||||
TOOL_COUNT_BY_SCOPE,
|
||||
} from './scope-catalog'
|
||||
export type { ApiKeyScope, ScopeGroup } from './scope-catalog'
|
||||
import { API_KEY_SCOPES, DEFAULT_SCOPES, type ApiKeyScope } from './scope-catalog'
|
||||
|
||||
export function validateScopes(scopes: unknown): ApiKeyScope[] | null {
|
||||
if (scopes === null || scopes === undefined) return null
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
/**
|
||||
* API-key scope catalogue: scope ids, labels, groups and the MCP tool map.
|
||||
*
|
||||
* Pure data with no server imports, so client components (the settings
|
||||
* panel) can bundle it without dragging in crypto or the service-role
|
||||
* Supabase client that lib/auth/api-keys.ts needs. api-keys.ts re-exports
|
||||
* everything here; server code keeps importing from there.
|
||||
*/
|
||||
|
||||
// ── API Key Scopes ──────────────────────────────────────────
|
||||
|
||||
export const API_KEY_SCOPES = {
|
||||
'transactions:read': { label: 'Transaktioner: läs', description: 'Lista transaktioner, mallförslag, kategoriförslag' },
|
||||
'transactions:write': { label: 'Transaktioner: skriv', description: 'Kategorisera, av-kategorisera, kvittomatchning, koppling mot faktura' },
|
||||
'customers:read': { label: 'Kunder: läs', description: 'Lista kunder' },
|
||||
'customers:write': { label: 'Kunder: skriv', description: 'Skapa och uppdatera kunder' },
|
||||
'articles:read': { label: 'Artiklar: läs', description: 'Lista artiklar i artikelregistret' },
|
||||
'articles:write': { label: 'Artiklar: skriv', description: 'Skapa och uppdatera artiklar' },
|
||||
'invoices:read': { label: 'Fakturor: läs', description: 'Lista fakturor' },
|
||||
'invoices:write': { label: 'Fakturor: skriv', description: 'Skapa, skicka, markera betald/skickad' },
|
||||
'suppliers:read': { label: 'Leverantörer: läs', description: 'Lista leverantörer och leverantörsfakturor, hitta verifikat-kandidater' },
|
||||
'suppliers:write': { label: 'Leverantörer: skriv', description: 'Skapa leverantörer; godkänn, kreditera, betal-länka och hantera leverantörsfakturor' },
|
||||
'reports:read': { label: 'Rapporter: läs', description: 'Kontoplan, huvudbok, balansräkning, resultaträkning, moms, KPI, reskontra, perioder, bankavstämning, SIE-export' },
|
||||
'bookkeeping:write': { label: 'Bokföring: skriv', description: 'Stänga/låsa perioder, ingående balans, bokslut, SIE-import, voucher-gap-förklaringar, kontoplan (skapa/ändra konton), verifikat-anteckningar' },
|
||||
'payroll:read': { label: 'Löner: läs', description: 'Lista anställda, lönekörningar, lönejournal, körjournal' },
|
||||
'payroll:write': { label: 'Löner: skriv', description: 'Skapa lönekörning, beräkna, generera AGI, logga körjournalresor' },
|
||||
// v1 REST API: added Phase 1
|
||||
'companies:read': { label: 'Företag: läs', description: 'Lista och visa företagsprofiler som API-nyckeln har tillgång till' },
|
||||
'companies:write': { label: 'Företag: skriv', description: 'Skapa nya företag och uppdatera företagsinställningar (gnubok_create_company, stagade verktyg, REST POST /api/v1/companies och PATCH /api/v1/companies/{companyId}/settings)' },
|
||||
'events:read': { label: 'Händelser: läs', description: 'Polla händelseloggen (event_log) som webhook-fallback' },
|
||||
'webhooks:manage': { label: 'Webhooks: hantera', description: 'Skapa, lista, uppdatera och radera webhook-prenumerationer' },
|
||||
'operations:read': { label: 'Operationer: läs', description: 'Hämta status för långkörande operationer (importer, bokslut, omvärdering)' },
|
||||
'documents:read': { label: 'Dokument: läs', description: 'Lista och hämta dokumentbilagor' },
|
||||
'documents:write': { label: 'Dokument: skriv', description: 'Ladda upp och koppla dokument till verifikationer' },
|
||||
'compliance:read': { label: 'Compliance: läs', description: 'Pre-flight-kontroller: momsstängning, bokslutsberedskap, voucher-gap, IB/UB-kontinuitet; Skatteverket-status (moms + AGI)' },
|
||||
'skatteverket:write': { label: 'Skatteverket: skriv', description: 'Lämna momsdeklaration och arbetsgivardeklaration (AGI) till Skatteverket (stagas; signeras med BankID)' },
|
||||
'agent:read': { label: 'Agent: läs', description: 'Specialiserad bokföringsassistent: profil, laddade specialister/atomer, minnen (briefing + skill-katalog)' },
|
||||
'agent:write': { label: 'Agent: skriv', description: 'Spara och ta bort agentens minnen om företaget (remember_fact, forget_fact)' },
|
||||
'pending_operations:read': { label: 'Stagade operationer: läs', description: 'Lista pending_operations (staged writes awaiting approval)' },
|
||||
'pending_operations:approve': { label: 'Stagade operationer: godkänn', description: 'Godkänn eller avvisa stagade operationer via API/MCP: agenten ersätter web-UI:s granskning' },
|
||||
// Reconciliation (account-keyed: bank accounts + skattekonto). Reads cover
|
||||
// the account list, the bridge and the item buckets; writes cover links
|
||||
// (match/unmatch) and ignore flags. Links never touch the ledger.
|
||||
'reconciliation:read': { label: 'Avstämning: läs', description: 'Konton att stämma av, bryggan per konto och raderna bakom den (bank + skattekonto)' },
|
||||
'reconciliation:write': { label: 'Avstämning: skriv', description: 'Koppla och koppla bort händelser mot verifikat, ignorera rader (MCP stagar; REST skriver direkt)' },
|
||||
'reconciliation:signoff': { label: 'Avstämning: signera', description: 'Markera ett konto som avstämt t.o.m. ett datum och öppna en signering igen (MCP stagar; REST skriver direkt)' },
|
||||
} as const
|
||||
|
||||
export type ApiKeyScope = keyof typeof API_KEY_SCOPES
|
||||
|
||||
export const ALL_SCOPES: ApiKeyScope[] = Object.keys(API_KEY_SCOPES) as ApiKeyScope[]
|
||||
|
||||
/** The read-only scopes assigned to keys with no explicit scopes (legacy/null). */
|
||||
export const DEFAULT_SCOPES: ApiKeyScope[] = [
|
||||
'transactions:read',
|
||||
'customers:read',
|
||||
'articles:read',
|
||||
'invoices:read',
|
||||
'suppliers:read',
|
||||
'reports:read',
|
||||
]
|
||||
|
||||
/**
|
||||
* Default scope grant for OAuth-issued keys when the client did not pass an
|
||||
* explicit `scope` parameter at /authorize. Read-only by design: every
|
||||
* write or approval scope must be requested explicitly by the client AND
|
||||
* affirmatively ticked by the user on the consent screen.
|
||||
*
|
||||
* Rationale (do not weaken without a documented security decision):
|
||||
* - GDPR Art. 25(2) data-protection-by-default: the minimum-necessary
|
||||
* access set must be the silent baseline.
|
||||
* - ISO 27001:2022 A.5.18 / A.8.2 / SOC 2 CC6.3: privileged capabilities
|
||||
* (write, approve) must not be bundled into a default grant.
|
||||
* - Segregation of Duties (findStageApproveConflict below): granting any
|
||||
* STAGING_SCOPES member together with `pending_operations:approve` on a
|
||||
* single key lets an automated agent both stage AND commit financial
|
||||
* postings without a human-in-the-loop review. Keeping the default
|
||||
* read-only prevents this combination from being silently issued.
|
||||
* - BFL 5 kap 5§ / BFNAR 2013:2 behandlingshistorik: write paths that
|
||||
* create or modify verifikationer must be opt-in at the authorization
|
||||
* layer; conversational acknowledgement at the agent layer is not an
|
||||
* auditable substitute.
|
||||
*/
|
||||
export const DEFAULT_OAUTH_SCOPES: ApiKeyScope[] = [
|
||||
'transactions:read',
|
||||
'customers:read',
|
||||
'articles:read',
|
||||
'invoices:read',
|
||||
'suppliers:read',
|
||||
'reports:read',
|
||||
'companies:read',
|
||||
'events:read',
|
||||
'operations:read',
|
||||
'documents:read',
|
||||
'compliance:read',
|
||||
'payroll:read',
|
||||
'pending_operations:read',
|
||||
]
|
||||
|
||||
/**
|
||||
* Scopes advertised in the RFC 8414 authorization-server metadata document
|
||||
* (/.well-known/oauth-authorization-server). Restricted to the same set that
|
||||
* /authorize will grant by default: destructive scopes still work when
|
||||
* requested explicitly, they just aren't enumerated for unauthenticated
|
||||
* callers (defense-in-depth against scope-escalation reconnaissance).
|
||||
*/
|
||||
export const PUBLIC_OAUTH_METADATA_SCOPES: ApiKeyScope[] = [...DEFAULT_OAUTH_SCOPES]
|
||||
|
||||
/**
|
||||
* Scopes that allow staging a pending_operation. Used to detect a
|
||||
* segregation-of-duties conflict when paired with `pending_operations:approve`
|
||||
* on the same API key (ISO 27001:2022 A.5.3, SOC 2 CC6.1).
|
||||
*
|
||||
* Documented system control (BFNAR 2013:2 systemdokumentation): `agent:write`
|
||||
* is deliberately NOT a staging scope. The memory tools it gates
|
||||
* (gnubok_remember_fact/forget_fact) write advisory agent context: they
|
||||
* cannot create, mutate, or stage räkenskapsinformation, so memory-write +
|
||||
* approve on one key does not let an agent both stage and commit bookkeeping.
|
||||
* If a future memory surface ever feeds DIRECTLY into voucher generation
|
||||
* (rather than via a separately staged-and-approved operation), revisit this
|
||||
* classification.
|
||||
*/
|
||||
export const STAGING_SCOPES: ApiKeyScope[] = [
|
||||
'transactions:write',
|
||||
'customers:write',
|
||||
'articles:write',
|
||||
'invoices:write',
|
||||
'suppliers:write',
|
||||
'bookkeeping:write',
|
||||
'payroll:write',
|
||||
'documents:write',
|
||||
'companies:write',
|
||||
// Skatteverket submit tools stage submit_vat_declaration / submit_agi, so a
|
||||
// key holding both this and pending_operations:approve is a SoD conflict:
|
||||
// findStageApproveConflict picks it up automatically from this list.
|
||||
'skatteverket:write',
|
||||
// gnubok_reconcile_match / gnubok_reconcile_unmatch stage reconciliation_*
|
||||
// operations; same SoD reasoning.
|
||||
'reconciliation:write',
|
||||
// gnubok_reconcile_signoff stages reconciliation_signoff.
|
||||
'reconciliation:signoff',
|
||||
]
|
||||
|
||||
/**
|
||||
* Detect a segregation-of-duties conflict between staging and approval scopes
|
||||
* on the same key. Returns the offending staging scope, or null when the
|
||||
* combination is clean. Callers may choose to block, warn, or record an
|
||||
* acknowledged risk acceptance.
|
||||
*
|
||||
* Granting both stage+approve to the same actor lets an automated agent both
|
||||
* stage AND commit financial postings without a human-in-the-loop review,
|
||||
* which is the explicit control surface for BFNAR 2013:2 (behandlingshistorik)
|
||||
* and BFL 5 kap 5§ traceability requirements.
|
||||
*/
|
||||
export function findStageApproveConflict(scopes: ApiKeyScope[]): ApiKeyScope | null {
|
||||
if (!scopes.includes('pending_operations:approve')) return null
|
||||
return scopes.find((s) => STAGING_SCOPES.includes(s)) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* One entry per scope group, shared by every surface that lets a human pick
|
||||
* scopes (settings panel, OAuth consent page). Every scope in API_KEY_SCOPES
|
||||
* belongs to exactly one group: lib/auth/__tests__/scope-catalog.test.ts
|
||||
* enforces it, so a scope added to the catalogue without a group fails CI
|
||||
* instead of silently vanishing from the pickers.
|
||||
*/
|
||||
export type ScopeGroup = {
|
||||
/** Stable id: React key and i18n suffix (`group_<domain>`) in the panel. */
|
||||
domain: string
|
||||
/** Swedish label for surfaces without next-intl (the OAuth consent page). */
|
||||
label: string
|
||||
/** Display order: the `:read` scope first, then the elevated ones. */
|
||||
scopes: readonly ApiKeyScope[]
|
||||
}
|
||||
|
||||
export const SCOPE_GROUPS: readonly ScopeGroup[] = [
|
||||
{ domain: 'companies', label: 'Företag', scopes: ['companies:read', 'companies:write'] },
|
||||
{ domain: 'transactions', label: 'Transaktioner', scopes: ['transactions:read', 'transactions:write'] },
|
||||
{ domain: 'reconciliation', label: 'Avstämning', scopes: ['reconciliation:read', 'reconciliation:write', 'reconciliation:signoff'] },
|
||||
{ domain: 'customers', label: 'Kunder', scopes: ['customers:read', 'customers:write'] },
|
||||
{ domain: 'articles', label: 'Artiklar', scopes: ['articles:read', 'articles:write'] },
|
||||
{ domain: 'invoices', label: 'Fakturor', scopes: ['invoices:read', 'invoices:write'] },
|
||||
{ domain: 'suppliers', label: 'Leverantörer', scopes: ['suppliers:read', 'suppliers:write'] },
|
||||
{ domain: 'reports', label: 'Rapporter', scopes: ['reports:read'] },
|
||||
{ domain: 'bookkeeping', label: 'Bokföring', scopes: ['bookkeeping:write'] },
|
||||
{ domain: 'payroll', label: 'Löner', scopes: ['payroll:read', 'payroll:write'] },
|
||||
{ domain: 'documents', label: 'Dokument', scopes: ['documents:read', 'documents:write'] },
|
||||
{ domain: 'pending_operations', label: 'Stagade operationer', scopes: ['pending_operations:read', 'pending_operations:approve'] },
|
||||
{ domain: 'agent', label: 'Agent', scopes: ['agent:read', 'agent:write'] },
|
||||
{ domain: 'skatteverket', label: 'Skatteverket', scopes: ['skatteverket:write'] },
|
||||
{ domain: 'compliance', label: 'Compliance', scopes: ['compliance:read'] },
|
||||
{ domain: 'events', label: 'Händelser', scopes: ['events:read'] },
|
||||
{ domain: 'webhooks', label: 'Webhooks', scopes: ['webhooks:manage'] },
|
||||
{ domain: 'operations', label: 'Operationer', scopes: ['operations:read'] },
|
||||
]
|
||||
|
||||
/**
|
||||
* Read scopes are the implicit baseline; everything else (write, manage,
|
||||
* approve, signoff) is an elevated grant and is rendered as such.
|
||||
*/
|
||||
export function scopeKind(scope: ApiKeyScope): 'read' | 'write' {
|
||||
return scope.endsWith(':read') ? 'read' : 'write'
|
||||
}
|
||||
|
||||
/** Map MCP tool name → required scope. Tools omitted from this map are available to any authenticated key (e.g. discovery/search/skill loading). */
|
||||
export const TOOL_SCOPE_MAP: Record<string, ApiKeyScope> = {
|
||||
// Companies
|
||||
gnubok_list_companies: 'companies:read',
|
||||
gnubok_create_company: 'companies:write',
|
||||
gnubok_lookup_company: 'companies:read',
|
||||
gnubok_connect_bank: 'companies:read',
|
||||
gnubok_connect_skatteverket: 'companies:read',
|
||||
gnubok_get_company_settings: 'companies:read',
|
||||
gnubok_update_company_settings: 'companies:write',
|
||||
// Transactions
|
||||
gnubok_list_uncategorized_transactions: 'transactions:read',
|
||||
gnubok_list_cash_accounts: 'transactions:read',
|
||||
gnubok_list_transactions_without_documents: 'transactions:read',
|
||||
gnubok_create_transactions: 'transactions:write',
|
||||
gnubok_categorize_transaction: 'transactions:write',
|
||||
gnubok_receipt_matcher: 'transactions:write',
|
||||
gnubok_get_counterparty_templates: 'transactions:read',
|
||||
gnubok_suggest_categories: 'transactions:read',
|
||||
gnubok_match_transaction_to_invoice: 'transactions:write',
|
||||
gnubok_link_transaction_to_journal_entry: 'transactions:write',
|
||||
gnubok_match_batch_allocate: 'transactions:write',
|
||||
// Reconciliation (account-keyed). gnubok_get_reconciliation_status keeps its
|
||||
// historical reports:read so existing keys are not cut off.
|
||||
gnubok_list_reconciliation_items: 'reconciliation:read',
|
||||
gnubok_reconcile_match: 'reconciliation:write',
|
||||
gnubok_reconcile_unmatch: 'reconciliation:write',
|
||||
gnubok_reconcile_signoff: 'reconciliation:signoff',
|
||||
// Residual booking writes a verifikat: the same scope that books a bank row.
|
||||
gnubok_reconcile_residual: 'transactions:write',
|
||||
gnubok_bulk_book_transactions: 'transactions:write',
|
||||
gnubok_bulk_book_inbox_items: 'transactions:write',
|
||||
gnubok_auto_match_period: 'transactions:write',
|
||||
// Customers
|
||||
gnubok_list_customers: 'customers:read',
|
||||
gnubok_create_customer: 'customers:write',
|
||||
gnubok_update_customer: 'customers:write',
|
||||
// Articles (artikelregister)
|
||||
gnubok_list_articles: 'articles:read',
|
||||
gnubok_create_article: 'articles:write',
|
||||
gnubok_update_article: 'articles:write',
|
||||
// Invoices
|
||||
gnubok_list_invoices: 'invoices:read',
|
||||
gnubok_get_invoice_deliveries: 'invoices:read',
|
||||
gnubok_create_invoice: 'invoices:write',
|
||||
gnubok_update_invoice: 'invoices:write',
|
||||
gnubok_send_invoice: 'invoices:write',
|
||||
gnubok_mark_invoice_as_paid: 'invoices:write',
|
||||
gnubok_mark_invoice_as_sent: 'invoices:write',
|
||||
// Recurring invoice schedules (staged template writes; no send/book at commit)
|
||||
gnubok_list_recurring_schedules: 'invoices:read',
|
||||
gnubok_create_recurring_schedule: 'invoices:write',
|
||||
gnubok_update_recurring_schedule: 'invoices:write',
|
||||
// Suppliers
|
||||
gnubok_list_suppliers: 'suppliers:read',
|
||||
gnubok_list_supplier_invoices: 'suppliers:read',
|
||||
// Reports
|
||||
gnubok_get_trial_balance: 'reports:read',
|
||||
gnubok_get_vat_report: 'reports:read',
|
||||
gnubok_vat_review_widget: 'reports:read',
|
||||
gnubok_vat_close_check: 'reports:read',
|
||||
gnubok_get_kpi_report: 'reports:read',
|
||||
gnubok_get_income_statement: 'reports:read',
|
||||
gnubok_list_accounts: 'reports:read',
|
||||
// Kontoplan management: staged reference-data writes
|
||||
gnubok_create_account: 'bookkeeping:write',
|
||||
gnubok_update_account: 'bookkeeping:write',
|
||||
// Verifikat annotation (notes-only edit: allowed on posted entries)
|
||||
gnubok_set_voucher_note: 'bookkeeping:write',
|
||||
gnubok_get_balance_sheet: 'reports:read',
|
||||
gnubok_get_general_ledger: 'reports:read',
|
||||
gnubok_query_journal: 'reports:read',
|
||||
gnubok_get_ar_ledger: 'reports:read',
|
||||
gnubok_get_supplier_ledger: 'reports:read',
|
||||
gnubok_list_fiscal_periods: 'reports:read',
|
||||
gnubok_get_reconciliation_status: 'reports:read',
|
||||
gnubok_list_accrual_schedules: 'reports:read',
|
||||
// Dimensions (kostnadsställe/projekt) registry: reads next to the report
|
||||
// tools; the staged value-create is a bookkeeping write (dimensions PR3).
|
||||
gnubok_list_dimensions: 'reports:read',
|
||||
gnubok_list_dimension_values: 'reports:read',
|
||||
gnubok_create_dimension_value: 'bookkeeping:write',
|
||||
gnubok_get_dimension_pnl: 'reports:read',
|
||||
// Staged bulk retag of posted-line dimensions (dimensions PR6).
|
||||
gnubok_tag_journal_lines: 'bookkeeping:write',
|
||||
// Document inbox
|
||||
gnubok_create_document_upload: 'transactions:write',
|
||||
gnubok_complete_document_upload: 'transactions:write',
|
||||
gnubok_upload_document: 'transactions:write',
|
||||
gnubok_list_inbox_items: 'transactions:read',
|
||||
gnubok_get_inbox_item: 'transactions:read',
|
||||
gnubok_list_unmatched_documents: 'transactions:read',
|
||||
gnubok_get_document_content: 'transactions:read',
|
||||
gnubok_attach_document_to_transaction: 'transactions:write',
|
||||
gnubok_link_document_to_voucher: 'bookkeeping:write',
|
||||
gnubok_link_documents_to_vouchers: 'bookkeeping:write',
|
||||
// Körjournal (mileage): trip log reads/writes are payroll surface
|
||||
// (milersättning, 7331); booking the verifikat is a journal write.
|
||||
gnubok_list_mileage_trips: 'payroll:read',
|
||||
gnubok_log_mileage_trip: 'payroll:write',
|
||||
gnubok_book_mileage_period: 'bookkeeping:write',
|
||||
// Payroll
|
||||
gnubok_list_employees: 'payroll:read',
|
||||
gnubok_get_salary_run: 'payroll:read',
|
||||
gnubok_get_salary_journal: 'payroll:read',
|
||||
gnubok_create_salary_run: 'payroll:write',
|
||||
gnubok_calculate_salary_run: 'payroll:write',
|
||||
gnubok_book_salary_run: 'payroll:write',
|
||||
gnubok_generate_agi: 'payroll:write',
|
||||
// Payroll gap-closure: reads + staged writes (1.6-1.8, 2.4)
|
||||
gnubok_get_employee: 'payroll:read',
|
||||
gnubok_get_payslip: 'payroll:read',
|
||||
gnubok_list_absence: 'payroll:read',
|
||||
gnubok_update_payslip_line: 'payroll:write',
|
||||
gnubok_register_absence: 'payroll:write',
|
||||
gnubok_delete_absence: 'payroll:write',
|
||||
gnubok_create_employee: 'payroll:write',
|
||||
gnubok_update_employee: 'payroll:write',
|
||||
gnubok_set_employee_opening_balances: 'payroll:write',
|
||||
gnubok_get_vacation_balance: 'payroll:read',
|
||||
gnubok_close_vacation_year: 'payroll:write',
|
||||
// Bookkeeping write (Stream 1 Phase 1): high-risk, always staged
|
||||
gnubok_close_period: 'bookkeeping:write',
|
||||
gnubok_lock_period: 'bookkeeping:write',
|
||||
gnubok_unlock_period: 'bookkeeping:write',
|
||||
gnubok_run_year_end: 'bookkeeping:write',
|
||||
gnubok_post_kontantmetod_cutoff: 'bookkeeping:write',
|
||||
gnubok_year_end_readiness: 'reports:read',
|
||||
gnubok_set_opening_balances: 'bookkeeping:write',
|
||||
gnubok_run_currency_revaluation: 'bookkeeping:write',
|
||||
gnubok_explain_voucher_gap: 'bookkeeping:write',
|
||||
gnubok_list_voucher_gaps: 'reports:read',
|
||||
// Transaction reversal (medium-risk)
|
||||
gnubok_uncategorize_transaction: 'transactions:write',
|
||||
// SIE export (read-only) + import (write)
|
||||
gnubok_export_sie: 'reports:read',
|
||||
gnubok_audit_package: 'reports:read',
|
||||
gnubok_import_sie: 'bookkeeping:write',
|
||||
// Rot/rut begäran om utbetalning (records a payout request on generate)
|
||||
gnubok_generate_rot_rut_file: 'invoices:write',
|
||||
// Supplier CRUD
|
||||
gnubok_create_supplier: 'suppliers:write',
|
||||
// Supplier invoice lifecycle
|
||||
gnubok_approve_supplier_invoice: 'suppliers:write',
|
||||
gnubok_credit_supplier_invoice: 'suppliers:write',
|
||||
gnubok_create_supplier_invoice_from_inbox: 'suppliers:write',
|
||||
gnubok_set_inbox_extracted_data: 'suppliers:write',
|
||||
// Supplier invoice payment via existing verifikat (no new bokföring)
|
||||
gnubok_find_voucher_candidates_for_supplier_invoice: 'suppliers:read',
|
||||
gnubok_link_supplier_invoice_to_voucher: 'suppliers:write',
|
||||
// Invoice conversion + crediting
|
||||
gnubok_convert_invoice: 'invoices:write',
|
||||
gnubok_credit_invoice: 'invoices:write',
|
||||
// Phase 4: arbitrary-line bookkeeping primitives (high-risk, always staged)
|
||||
gnubok_create_voucher: 'bookkeeping:write',
|
||||
gnubok_correct_entry: 'bookkeeping:write',
|
||||
gnubok_reverse_journal_entry: 'bookkeeping:write',
|
||||
// Agent surface (Phase 6 MCP parity): briefing tool exposes company-specific
|
||||
// profile + memory so it's scoped; gnubok_list_skills / gnubok_load_skill
|
||||
// stay unscoped (discovery + static Markdown bodies + globally-readable atom
|
||||
// registry: no per-company data).
|
||||
gnubok_get_agent_briefing: 'agent:read',
|
||||
// Agent memory write (previously UNMAPPED → callable by any key). Mapping to
|
||||
// agent:write; existing non-revoked keys are grandfathered in the
|
||||
// 20260619140000 migration so this does not regress them.
|
||||
gnubok_remember_fact: 'agent:write',
|
||||
gnubok_forget_fact: 'agent:write',
|
||||
// Pending operations approval (mirrors the /pending web UI)
|
||||
gnubok_list_pending_operations: 'pending_operations:read',
|
||||
gnubok_approve_pending_operation: 'pending_operations:approve',
|
||||
gnubok_reject_pending_operation: 'pending_operations:approve',
|
||||
// Skatteverket filing (PR5). Reads are compliance:read (status of moms/AGI);
|
||||
// the two submit tools require the opt-in skatteverket:write staging scope.
|
||||
gnubok_vat_declaration_validate: 'compliance:read',
|
||||
gnubok_vat_declaration_status: 'compliance:read',
|
||||
gnubok_agi_status: 'compliance:read',
|
||||
gnubok_vat_declaration_submit: 'skatteverket:write',
|
||||
gnubok_agi_submit: 'skatteverket:write',
|
||||
|
||||
// ── Audit retrofit (agent-native audit P0: unmapped = default-allow) ──
|
||||
// These tools shipped without a scope mapping, making them callable by ANY
|
||||
// authenticated key. Mapping them is accept-the-break by decision
|
||||
// (2026-07-13): keys that relied on the default-allow hole lose access
|
||||
// until granted the proper scope. Release-note callout required for the
|
||||
// four WRITES below.
|
||||
gnubok_link_invoice_to_voucher: 'invoices:write',
|
||||
gnubok_undo_sie_import: 'bookkeeping:write',
|
||||
gnubok_post_annual_depreciation: 'bookkeeping:write',
|
||||
gnubok_import_rot_rut_beslut: 'invoices:write',
|
||||
gnubok_list_verifikat_without_documents: 'transactions:read',
|
||||
gnubok_find_voucher_candidates_for_invoice: 'invoices:read',
|
||||
gnubok_propose_dispositioner: 'reports:read',
|
||||
gnubok_propose_accruals: 'reports:read',
|
||||
gnubok_propose_annual_depreciation: 'reports:read',
|
||||
gnubok_preview_arsredovisning: 'reports:read',
|
||||
gnubok_validate_arsredovisning: 'reports:read',
|
||||
gnubok_list_arsredovisning_versions: 'reports:read',
|
||||
gnubok_get_arsredovisning_filing_status: 'reports:read',
|
||||
gnubok_preview_ef_declaration: 'reports:read',
|
||||
// Deliberately UNSCOPED (available to any authenticated key):
|
||||
// gnubok_search_tools, gnubok_list_skills, gnubok_load_skill,
|
||||
// gnubok_feedback. Discovery + static skill bodies + feedback channel
|
||||
// carry no per-company data; keeping them open is what lets an agent
|
||||
// orient itself before its key's scopes are known.
|
||||
}
|
||||
|
||||
/**
|
||||
* Number of MCP tools gated by each scope, derived from TOOL_SCOPE_MAP at
|
||||
* module load. 0 means the scope only gates REST endpoints. Never hand-write
|
||||
* these numbers into labels: they drift the moment a tool is added.
|
||||
*/
|
||||
export const TOOL_COUNT_BY_SCOPE: Readonly<Record<ApiKeyScope, number>> = (() => {
|
||||
const counts = Object.fromEntries(ALL_SCOPES.map((s) => [s, 0])) as Record<ApiKeyScope, number>
|
||||
for (const scope of Object.values(TOOL_SCOPE_MAP)) counts[scope] += 1
|
||||
return counts
|
||||
})()
|
||||
+15
-6
@@ -2737,16 +2737,24 @@
|
||||
"group_bookkeeping": "Bookkeeping",
|
||||
"group_payroll": "Payroll",
|
||||
"group_pending_operations": "Staged operations",
|
||||
"group_documents": "Documents (REST API)",
|
||||
"group_companies": "Companies (REST API)",
|
||||
"group_events": "Events (REST API)",
|
||||
"group_webhooks": "Webhooks (REST API)",
|
||||
"group_operations": "Operations (REST API)",
|
||||
"group_compliance": "Compliance (REST API)",
|
||||
"group_documents": "Documents",
|
||||
"group_companies": "Companies",
|
||||
"group_events": "Events",
|
||||
"group_webhooks": "Webhooks",
|
||||
"group_operations": "Operations",
|
||||
"group_compliance": "Compliance",
|
||||
"group_articles": "Articles",
|
||||
"group_reconciliation": "Reconciliation",
|
||||
"group_rest_only": "{name} (REST API)",
|
||||
"scope_transactions_read": "Read: list transactions, templates, categories, inbox",
|
||||
"scope_transactions_write": "Write: categorize, receipt matching, link to invoice, document upload",
|
||||
"scope_reconciliation_read": "Read: accounts to reconcile, the bridge per account and the items behind it (bank + tax account)",
|
||||
"scope_reconciliation_write": "Write: match and unmatch events against vouchers, ignore items (staged)",
|
||||
"scope_reconciliation_signoff": "Sign off: mark an account as reconciled through a date, or reopen a sign-off (staged)",
|
||||
"scope_customers_read": "Read: list customers",
|
||||
"scope_customers_write": "Write: create customers",
|
||||
"scope_articles_read": "Read: list articles in the article register",
|
||||
"scope_articles_write": "Write: create and update articles",
|
||||
"scope_invoices_read": "Read: list invoices",
|
||||
"scope_invoices_write": "Write: create, send, mark paid/sent, credit, convert",
|
||||
"scope_suppliers_read": "Read: list suppliers and supplier invoices",
|
||||
@@ -2760,6 +2768,7 @@
|
||||
"scope_documents_read": "Read: list and fetch document attachments",
|
||||
"scope_documents_write": "Write: upload and link documents to vouchers",
|
||||
"scope_companies_read": "Read: company profiles the key has access to",
|
||||
"scope_companies_write": "Write: create companies and update company settings",
|
||||
"scope_events_read": "Read: poll event_log as a webhook fallback",
|
||||
"scope_webhooks_manage": "Manage: create, list, update, delete subscriptions",
|
||||
"scope_operations_read": "Read: status of long-running operations (import, year-end, revaluation)",
|
||||
|
||||
+15
-6
@@ -2737,16 +2737,24 @@
|
||||
"group_bookkeeping": "Bokföring",
|
||||
"group_payroll": "Löner",
|
||||
"group_pending_operations": "Stagade operationer",
|
||||
"group_documents": "Dokument (REST API)",
|
||||
"group_companies": "Företag (REST API)",
|
||||
"group_events": "Händelser (REST API)",
|
||||
"group_webhooks": "Webhooks (REST API)",
|
||||
"group_operations": "Operationer (REST API)",
|
||||
"group_compliance": "Compliance (REST API)",
|
||||
"group_documents": "Dokument",
|
||||
"group_companies": "Företag",
|
||||
"group_events": "Händelser",
|
||||
"group_webhooks": "Webhooks",
|
||||
"group_operations": "Operationer",
|
||||
"group_compliance": "Compliance",
|
||||
"group_articles": "Artiklar",
|
||||
"group_reconciliation": "Avstämning",
|
||||
"group_rest_only": "{name} (REST API)",
|
||||
"scope_transactions_read": "Läs: lista transaktioner, mallar, kategorier, inbox",
|
||||
"scope_transactions_write": "Skriv: kategorisera, kvittomatchning, koppling mot faktura, dokumentuppladdning",
|
||||
"scope_reconciliation_read": "Läs: konton att stämma av, bryggan per konto och raderna bakom den (bank + skattekonto)",
|
||||
"scope_reconciliation_write": "Skriv: koppla och koppla bort händelser mot verifikat, ignorera rader (stagas)",
|
||||
"scope_reconciliation_signoff": "Signera: markera ett konto som avstämt t.o.m. ett datum, eller öppna signeringen igen (stagas)",
|
||||
"scope_customers_read": "Läs: lista kunder",
|
||||
"scope_customers_write": "Skriv: skapa kunder",
|
||||
"scope_articles_read": "Läs: lista artiklar i artikelregistret",
|
||||
"scope_articles_write": "Skriv: skapa och uppdatera artiklar",
|
||||
"scope_invoices_read": "Läs: lista fakturor",
|
||||
"scope_invoices_write": "Skriv: skapa, skicka, markera betald/skickad, kreditera, konvertera",
|
||||
"scope_suppliers_read": "Läs: lista leverantörer och leverantörsfakturor",
|
||||
@@ -2760,6 +2768,7 @@
|
||||
"scope_documents_read": "Läs: lista och hämta dokumentbilagor",
|
||||
"scope_documents_write": "Skriv: ladda upp och koppla dokument till verifikationer",
|
||||
"scope_companies_read": "Läs: företagsprofiler nyckeln har åtkomst till",
|
||||
"scope_companies_write": "Skriv: skapa företag och uppdatera företagsinställningar",
|
||||
"scope_events_read": "Läs: polla event_log som webhook-fallback",
|
||||
"scope_webhooks_manage": "Hantera: skapa, lista, uppdatera, radera prenumerationer",
|
||||
"scope_operations_read": "Läs: status för långkörande operationer (import, bokslut, omvärdering)",
|
||||
|
||||
Reference in New Issue
Block a user