188 lines
5.5 KiB
TypeScript
188 lines
5.5 KiB
TypeScript
import type { LegacyApiKeySimple } from "@toolctrl/db/api-keys.server";
|
|
import type { LegacyEntity } from "@toolctrl/db/entities.server";
|
|
import { describe, expect, test } from "vitest";
|
|
|
|
import { createServicePrincipal } from "../auth.js";
|
|
import { authorizeApiEntityRowScope, authorizeApiRelationshipScope } from "./permission.js";
|
|
import { createStoredApiKeyPrincipal, type ApiScopeResult } from "./scope.js";
|
|
|
|
const NOW = new Date("2026-01-15T12:00:00.000Z");
|
|
|
|
function buildEntity(overrides: Partial<LegacyEntity> = {}): LegacyEntity {
|
|
return {
|
|
id: "entity-1",
|
|
name: "contacts",
|
|
description: null,
|
|
url: null,
|
|
multiTenant: true,
|
|
isPublic: false,
|
|
useAsUser: false,
|
|
createdById: null,
|
|
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
|
updatedAt: new Date("2026-01-01T00:00:00.000Z"),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function buildApiKey(overrides: Partial<LegacyApiKeySimple> = {}): LegacyApiKeySimple {
|
|
return {
|
|
id: "api-key-1",
|
|
alias: "Production key",
|
|
expires: new Date("2026-02-01T00:00:00.000Z"),
|
|
tenantId: "tenant-1",
|
|
active: true,
|
|
entities: [
|
|
{
|
|
entityId: "entity-1",
|
|
create: true,
|
|
read: true,
|
|
update: true,
|
|
delete: false,
|
|
},
|
|
{
|
|
entityId: "child-entity",
|
|
create: true,
|
|
read: true,
|
|
update: false,
|
|
delete: false,
|
|
},
|
|
],
|
|
tenant: {
|
|
id: "tenant-1",
|
|
name: "Tenant One",
|
|
slug: "tenant-one",
|
|
deactivatedReason: null,
|
|
},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function expectRedactedDeny(result: ApiScopeResult, reason: string) {
|
|
expect(result).toEqual({
|
|
allowed: false,
|
|
principalType: "api-key",
|
|
reason,
|
|
code: "FORBIDDEN_API_KEY_SCOPE",
|
|
});
|
|
const serialized = JSON.stringify(result);
|
|
expect(serialized).not.toContain("plaintext-secret");
|
|
expect(serialized).not.toContain("hash-value");
|
|
expect(serialized).not.toContain("service-token-value");
|
|
}
|
|
|
|
describe("authorizeApiEntityRowScope", () => {
|
|
test("allows entity row operations when the stored API key has the entity operation scope", () => {
|
|
const result = authorizeApiEntityRowScope(createStoredApiKeyPrincipal(buildApiKey()), {
|
|
tenantId: "tenant-1",
|
|
entity: buildEntity(),
|
|
operation: "read",
|
|
now: NOW,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
allowed: true,
|
|
principalType: "api-key",
|
|
});
|
|
});
|
|
|
|
test("denies entity row operations when the stored API key lacks the entity operation scope", () => {
|
|
const result = authorizeApiEntityRowScope(createStoredApiKeyPrincipal(buildApiKey()), {
|
|
tenantId: "tenant-1",
|
|
entity: buildEntity(),
|
|
operation: "delete",
|
|
now: NOW,
|
|
});
|
|
|
|
expectRedactedDeny(result, "operation_permission_missing");
|
|
});
|
|
|
|
test("fails closed for malformed legacy entities without an id", () => {
|
|
expect(() =>
|
|
authorizeApiEntityRowScope(createStoredApiKeyPrincipal(buildApiKey()), {
|
|
tenantId: "tenant-1",
|
|
entity: buildEntity({ id: "" }),
|
|
operation: "read",
|
|
now: NOW,
|
|
}),
|
|
).toThrow("Entity is missing an id.");
|
|
});
|
|
|
|
test("denies stored API keys when tenant scope cannot be resolved", () => {
|
|
const result = authorizeApiEntityRowScope(createStoredApiKeyPrincipal(buildApiKey()), {
|
|
tenantId: null,
|
|
entity: buildEntity(),
|
|
operation: "read",
|
|
now: NOW,
|
|
});
|
|
|
|
expectRedactedDeny(result, "tenant_mismatch");
|
|
});
|
|
});
|
|
|
|
describe("authorizeApiRelationshipScope", () => {
|
|
test("allows service-token principals for relationship operations without entity scopes", () => {
|
|
const result = authorizeApiRelationshipScope(createServicePrincipal(), {
|
|
tenantId: "tenant-1",
|
|
parentEntityId: "missing-parent",
|
|
childEntityId: "missing-child",
|
|
operation: "delete",
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
allowed: true,
|
|
principalType: "service-token",
|
|
});
|
|
});
|
|
|
|
test("denies relationship operations when the parent entity lacks the requested operation", () => {
|
|
const result = authorizeApiRelationshipScope(createStoredApiKeyPrincipal(buildApiKey()), {
|
|
tenantId: "tenant-1",
|
|
parentEntityId: "missing-parent",
|
|
childEntityId: "child-entity",
|
|
operation: "read",
|
|
now: NOW,
|
|
});
|
|
|
|
expectRedactedDeny(result, "entity_permission_missing");
|
|
});
|
|
|
|
test("denies relationship operations when the child entity lacks the requested operation", () => {
|
|
const result = authorizeApiRelationshipScope(createStoredApiKeyPrincipal(buildApiKey()), {
|
|
tenantId: "tenant-1",
|
|
parentEntityId: "entity-1",
|
|
childEntityId: "child-entity",
|
|
operation: "update",
|
|
now: NOW,
|
|
});
|
|
|
|
expectRedactedDeny(result, "operation_permission_missing");
|
|
});
|
|
|
|
test("allows relationship operations when parent and child entities both include the requested operation", () => {
|
|
const result = authorizeApiRelationshipScope(createStoredApiKeyPrincipal(buildApiKey()), {
|
|
tenantId: "tenant-1",
|
|
parentEntityId: "entity-1",
|
|
childEntityId: "child-entity",
|
|
operation: "create",
|
|
now: NOW,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
allowed: true,
|
|
principalType: "api-key",
|
|
});
|
|
});
|
|
|
|
test("denies stored API keys for relationship operations when tenant scope cannot be resolved", () => {
|
|
const result = authorizeApiRelationshipScope(createStoredApiKeyPrincipal(buildApiKey()), {
|
|
tenantId: null,
|
|
parentEntityId: "entity-1",
|
|
childEntityId: "child-entity",
|
|
operation: "read",
|
|
now: NOW,
|
|
});
|
|
|
|
expectRedactedDeny(result, "tenant_mismatch");
|
|
});
|
|
});
|