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
5 changes: 2 additions & 3 deletions app/.server/__tests__/hostCapabilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@ vi.mock("~/.server/providers/providerCatalog.server", () => ({
invalidateProviderCatalogCache: vi.fn(),
clearProviderCatalogCache: vi.fn(),
findProviderConnection: vi.fn(),
findProviderRole: vi.fn(),
}));

vi.mock("~/.server/auth/getSessionCredentials", () => ({
Expand Down Expand Up @@ -438,7 +437,7 @@ describe("JobLedger tenant isolation (SDS-CY-080900/010099)", () => {
vi.spyOn(prisma.connectionConfig, "findFirst").mockResolvedValue({
id: "c1",
providerConnectionId: "pc-1",
grants: [{ providerRoleId: "pr-1" }],
grants: [{ accessLevel: "read-only" }],
} as never);
getProviderCatalogMock.mockResolvedValueOnce(EMPTY_CATALOG);
resolveConnectionProviderWithGrantsMock.mockReturnValueOnce({
Expand Down Expand Up @@ -496,7 +495,7 @@ describe("JobLedger tenant isolation (SDS-CY-080900/010099)", () => {
vi.spyOn(prisma.connectionConfig, "findFirst").mockResolvedValue({
id: "c1",
providerConnectionId: "pc-1",
grants: [{ providerRoleId: "pr-1" }],
grants: [{ accessLevel: "read-only" }],
} as never);
getProviderCatalogMock.mockResolvedValueOnce(EMPTY_CATALOG);
resolveConnectionProviderWithGrantsMock.mockReturnValueOnce({
Expand Down
85 changes: 72 additions & 13 deletions app/.server/auth/__tests__/getSessionCredentials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from "../getSessionCredentials";
import { buildSessionPolicy } from "../sessionPolicy";
import type { SessionData } from "../sessionStorage";
import { getBucketCatalog } from "~/.server/providers/bucketCatalog.server";
import { getProviderCatalog } from "~/.server/providers/providerCatalog.server";
import mock from "~/utils/__tests__/__mocks__";
import type { AccessLevel } from "~/utils/providerCatalog.schema";
Expand Down Expand Up @@ -36,6 +37,25 @@ vi.mock("~/.server/providers/providerCatalog.server", async (importOriginal) =>
return { ...actual, getProviderCatalog: vi.fn() };
});

vi.mock("~/.server/providers/bucketCatalog.server", async (importOriginal) => {
const actual = await importOriginal<typeof import("~/.server/providers/bucketCatalog.server")>();
return {
...actual,
getBucketCatalog: vi.fn(),
findBucketByName: actual.findBucketByName,
};
});

vi.mock("~/config", async (importOriginal) => {
const actual = await importOriginal<typeof import("~/config")>();
return {
cytarioConfig: {
...actual.cytarioConfig,
providers: { ...actual.cytarioConfig.providers, source: "portal" },
},
};
});

describe("isValidCredentials", () => {
test("returns false when credentials are undefined", () => {
expect(isValidCredentials(undefined)).toBe(false);
Expand Down Expand Up @@ -114,15 +134,13 @@ describe("getAllSessionCredentials", () => {
const catalogFor = (
overrides: {
providerConnectionId?: string;
providerRoleId?: string;
endpoint?: string | null;
region?: string;
roleArn?: string;
accessLevel?: AccessLevel;
} = {},
) => {
const pcId = overrides.providerConnectionId ?? "pc-mock";
const prId = overrides.providerRoleId ?? "pr-mock";
return mock.providerCatalog({
providerConnections: [
mock.providerConnection({
Expand All @@ -133,10 +151,10 @@ describe("getAllSessionCredentials", () => {
],
providerRoles: [
mock.providerRole({
id: prId,
providerConnectionId: pcId,
roleArn: overrides.roleArn ?? "arn:aws:iam::123456789012:role/mock-role",
accessLevel: overrides.accessLevel ?? "read-write",
bucketIds: ["bucket-mock-id"],
}),
],
});
Expand Down Expand Up @@ -204,13 +222,57 @@ describe("getAllSessionCredentials", () => {
expect(mockSend).toHaveBeenCalledTimes(1);
});

test("uses the bucket's registered region over the connection's", async () => {
vi.mocked(getBucketCatalog).mockResolvedValue(
mock.bucketCatalog({
buckets: [
mock.bucketLookupRow({
providerConnectionId: "pc-mock",
bucketName: "mock-bucket",
region: "us-west-2",
}),
],
}),
);

const result = await getAllSessionCredentials(mockSessionData, [
mock.connectionConfig({ id: "region-conn", name: "region-conn" }),
]);

expect(result.credentials["region-conn"]).toEqual(mockCredentials);
// The session policy (inline in the STS command) and the client both
// receive the bucket's region; the provider projection ships it too.
expect(result.providers["region-conn"]?.region).toBe("us-west-2");
const policyArg = vi.mocked(AssumeRoleWithWebIdentityCommand).mock.calls.at(-1)?.[0];
const policy = JSON.parse(String((policyArg as { Policy?: string }).Policy));
const kms = policy.Statement.find((s: { Sid?: string }) => s.Sid?.includes("KmsDecrypt"));
expect(kms.Condition.StringEquals["kms:ViaService"]).toBe("s3.us-west-2.amazonaws.com");
});

test("falls back to the connection region when the bucket catalog is unavailable", async () => {
vi.mocked(getBucketCatalog).mockRejectedValue(new Error("lookup unavailable"));

const result = await getAllSessionCredentials(mockSessionData, [
mock.connectionConfig({ id: "fallback-conn", name: "fallback-conn" }),
]);

expect(result.credentials["fallback-conn"]).toEqual(mockCredentials);
expect(result.providers["fallback-conn"]?.region).toBe("us-east-1");
});

test("mints separately for connections resolving to different roles", async () => {
vi.mocked(getProviderCatalog).mockResolvedValue(
mock.providerCatalog({
providerConnections: [mock.providerConnection({ id: "pc-mock" })],
providerRoles: [
mock.providerRole({ id: "pr-internal", roleArn: "arn:aws:iam::123:role/internal" }),
mock.providerRole({ id: "pr-external", roleArn: "arn:aws:iam::123:role/external" }),
mock.providerRole({
roleArn: "arn:aws:iam::123:role/internal",
accessLevel: "annotate",
}),
mock.providerRole({
roleArn: "arn:aws:iam::123:role/external",
accessLevel: "read-write",
}),
],
}),
);
Expand All @@ -220,13 +282,13 @@ describe("getAllSessionCredentials", () => {
name: "internal",
id: "internal",
bucketName: "shared-bucket",
grants: [mock.connectionGrant({ providerRoleId: "pr-internal" })],
grants: [mock.connectionGrant({ accessLevel: "annotate" })],
}),
mock.connectionConfig({
name: "external",
id: "external",
bucketName: "shared-bucket",
grants: [mock.connectionGrant({ providerRoleId: "pr-external" })],
grants: [mock.connectionGrant({ accessLevel: "read-write" })],
}),
];

Expand Down Expand Up @@ -407,19 +469,16 @@ describe("getAllSessionCredentials", () => {
providerConnections: [mock.providerConnection({ id: providerId })],
providerRoles: [
mock.providerRole({
id: "pr-ro",
providerConnectionId: providerId,
roleArn: "arn:aws:iam::123:role/read-only",
accessLevel: "read-only",
}),
mock.providerRole({
id: "pr-ann",
providerConnectionId: providerId,
roleArn: "arn:aws:iam::123:role/annotate",
accessLevel: "annotate",
}),
mock.providerRole({
id: "pr-rw",
providerConnectionId: providerId,
roleArn: "arn:aws:iam::123:role/read-write",
accessLevel: "read-write",
Expand All @@ -435,9 +494,9 @@ describe("getAllSessionCredentials", () => {
grants: [
// Grants are ordered least-permissive-first; the selector must rank by
// access level rather than rely on insertion order.
mock.connectionGrant({ providerRoleId: "pr-ro", scope: "org1/lab" }),
mock.connectionGrant({ providerRoleId: "pr-ann", scope: "org1/lab" }),
mock.connectionGrant({ providerRoleId: "pr-rw", scope: "org1/lab" }),
mock.connectionGrant({ accessLevel: "read-only", scope: "org1/lab" }),
mock.connectionGrant({ accessLevel: "annotate", scope: "org1/lab" }),
mock.connectionGrant({ accessLevel: "read-write", scope: "org1/lab" }),
],
});

Expand Down
21 changes: 18 additions & 3 deletions app/.server/auth/__tests__/sessionPolicy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,12 +287,27 @@ describe("buildSessionPolicy", () => {
expect(policy.Statement.some((s) => s.Sid === "KmsGenerateDataKeyViaS3")).toBe(false);
});

test("accessLevel annotate → includes PutOwnSidecars, omits PutObjectScopedToPrefix and kms:GenerateDataKey", () => {
test("accessLevel annotate → includes PutOwnSidecars and kms:GenerateDataKey, omits PutObjectScopedToPrefix", () => {
const policy = parse(buildSessionPolicy(args({ prefix: "foo", accessLevel: "annotate" })));
expect(policy.Statement.some((s) => s.Sid === "PutObjectScopedToPrefix")).toBe(false);
expect(policy.Statement.some((s) => s.Sid === "PutOwnSidecars")).toBe(true);
// Sidecars are small JSON files, not SSE-KMS-encrypted.
expect(policy.Statement.some((s) => s.Sid === "KmsGenerateDataKeyViaS3")).toBe(false);
// Sidecar PutObject on an SSE-KMS bucket needs data-key generation too.
const generate = findBySid(policy, "KmsGenerateDataKeyViaS3");
expect(generate.Effect).toBe("Allow");
expect(generate.Resource).toBe("*");
expect(generate.Condition?.StringEquals?.["kms:ViaService"]).toBe(`s3.${REGION}.amazonaws.com`);
});

test("annotate policy with a max-realistic prefix (64 chars) stays within the 2048-char ceiling", () => {
const json = buildSessionPolicy(
args({
bucketName: "my-bucket-with-some-length",
prefix: "a".repeat(64),
accessLevel: "annotate",
}),
);
expect(json.length).toBeLessThanOrEqual(POLICY_SIZE_CEILING);
expect(parse(json).Statement.some((s) => s.Sid === "KmsGenerateDataKeyViaS3")).toBe(true);
});

test("every sidecar-writing level includes DeleteAnnotationSidecars scoped to annotation sidecars (C-456)", () => {
Expand Down
54 changes: 47 additions & 7 deletions app/.server/auth/getSessionCredentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,15 @@ import { type ConnectionsCredentials, type SessionData } from "./sessionStorage"
import type { ConnectionConfig, ConnectionGrant } from "~/.generated/client";
import type { UserProfile } from "~/.server/auth/getUserInfo";
import { createLabel } from "~/.server/logging";
import { findBucketByName, getBucketCatalog } from "~/.server/providers/bucketCatalog.server";
import {
type ResolvedConnectionGrant,
type ResolvedConnectionProviderWithGrants,
getProviderCatalog,
resolveConnectionProviderWithGrants,
} from "~/.server/providers/providerCatalog.server";
import { sanitizeRoleSessionName } from "~/.server/stsSession";
import { cytarioConfig } from "~/config";
import { canSee } from "~/utils/authorization";
import { STS_STALENESS_BUFFER_MS } from "~/utils/credentialsRefresh";
import {
Expand All @@ -36,6 +38,10 @@ interface SessionCredentialRequest {
connectionConfig: ConnectionConfig;
grant: ResolvedConnectionGrant;
connectionProvider: ResolvedConnectionProviderWithGrants;
/** The bucket's registered region when known (portal builds); the bucket
* catalog is the per-bucket source — a bucket may live in a different
* region than its provider connection's default. */
bucketRegion?: string;
sessionData: SessionData;
roleSessionName: string;
}
Expand All @@ -44,12 +50,14 @@ const fetchTemporaryCredentials = async ({
connectionConfig,
grant,
connectionProvider,
bucketRegion,
sessionData,
roleSessionName,
}: SessionCredentialRequest): Promise<Credentials> => {
const { bucketName, prefix } = connectionConfig;
const { roleArn, accessLevel } = grant;
const { region, endpoint } = connectionProvider;
const region = bucketRegion ?? connectionProvider.region;
const { endpoint } = connectionProvider;
const { idToken } = sessionData.authTokens;

const providerConfig = getS3ProviderConfig(endpoint, region);
Expand Down Expand Up @@ -160,9 +168,10 @@ export interface SessionCredentialsResult {
* Fetches credentials for all connection configs in parallel.
*
* A connection no longer carries its own provider/endpoint/roleArn/region — those
* live on the portal-managed (or OSS-configured) provider connection + provider
* role the connection references. We resolve each connection's concrete AWS
* attributes from the organization's provider catalog before minting.
* live on the portal-managed (or OSS-configured) provider connection the
* connection references, and each grant's access level maps to a storage role
* resolved from the organization's provider catalog (bucket-scoped when the
* bucket catalog is available) before minting.
*
* Keys credentials by `config.name` so connections that share a bucket but resolve
* to different roles each get their own STS mint. Only fetches for connections
Expand Down Expand Up @@ -192,17 +201,43 @@ export const getAllSessionCredentials = async (
console.warn(`${label} Provider catalog lookup failed: ${catalogError}`);
}

// The bucket catalog carries each bucket's registered region — a bucket can
// live in a different region than its provider connection's default, and the
// session policy's kms:ViaService condition must name the bucket's region.
// Portal builds only; unavailability degrades to the connection region.
let bucketCatalog: Awaited<ReturnType<typeof getBucketCatalog>> | undefined;
if (cytarioConfig.providers.source === "portal") {
try {
bucketCatalog = await getBucketCatalog(organization, sessionData.authTokens.accessToken);
} catch {
bucketCatalog = undefined;
}
}

const bucketRegionOf = (connectionConfig: ConnectionConfig): string | undefined =>
bucketCatalog
? (findBucketByName(
bucketCatalog,
connectionConfig.providerConnectionId,
connectionConfig.bucketName,
)?.region ?? undefined)
: undefined;

// Resolve the non-secret provider attributes (region/endpoint) for every
// connection so the client data-plane can address the bucket even when the STS
// credential is still cached and no mint runs this request.
const providers: Record<string, ClientConnectionProvider> = {};
if (catalog) {
for (const connectionConfig of connectionConfigs) {
const connectionProvider = resolveConnectionProviderWithGrants(catalog, connectionConfig);
const connectionProvider = resolveConnectionProviderWithGrants(
catalog,
connectionConfig,
bucketCatalog,
);
if (connectionProvider) {
const grant = pickGrantForUser(connectionProvider, sessionData.user, organization);
providers[connectionConfig.id] = {
region: connectionProvider.region,
region: bucketRegionOf(connectionConfig) ?? connectionProvider.region,
endpoint: connectionProvider.endpoint,
allowsSharing: connectionProvider.allowsSharing,
accessLevel: grant?.accessLevel ?? "read-only",
Expand All @@ -228,7 +263,11 @@ export const getAllSessionCredentials = async (
if (!catalog) {
throw new Error(catalogError ?? "Provider catalog is unavailable.");
}
const connectionProvider = resolveConnectionProviderWithGrants(catalog, connectionConfig);
const connectionProvider = resolveConnectionProviderWithGrants(
catalog,
connectionConfig,
bucketCatalog,
);
if (!connectionProvider) {
throw new Error(
"This connection references a provider connection or role that is no longer available. Ask an administrator to check the storage onboarding.",
Expand All @@ -245,6 +284,7 @@ export const getAllSessionCredentials = async (
connectionConfig,
grant,
connectionProvider,
bucketRegion: bucketRegionOf(connectionConfig),
sessionData,
roleSessionName,
}),
Expand Down
5 changes: 4 additions & 1 deletion app/.server/auth/sessionPolicy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,10 @@ export const buildSessionPolicy = ({

if (permitsPrefixWrite(accessLevel)) {
statements.push(getPutObjectStatement(bucketArn, prefix));
// Writing to an SSE-KMS-encrypted bucket requires kms:GenerateDataKey.
}

// Sidecar writes to an SSE-KMS-encrypted bucket require data-key generation.
if (permitsSidecarWrite(accessLevel)) {
statements.push(getKmsStatement("kms:GenerateDataKey", region));
}

Expand Down
12 changes: 4 additions & 8 deletions app/.server/hostCapabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import type {
import { pickGrantForUser } from "~/.server/auth/getSessionCredentials";
import {
getProviderCatalog,
resolveConnectionProvider,
resolveConnectionProviderWithGrants,
} from "~/.server/providers/providerCatalog.server";
import { listConnections } from "~/routes/connections/connections.server";
Expand Down Expand Up @@ -46,18 +45,15 @@ async function toConnectionProjection(
accessToken: string,
): Promise<ConnectionProjection> {
const catalog = await getProviderCatalog(config.organization, accessToken);
const connectionProvider = resolveConnectionProvider(catalog, {
providerConnectionId: config.providerConnectionId,
providerRoleId: config.grants[0]?.providerRoleId ?? "",
});
const resolved = resolveConnectionProviderWithGrants(catalog, config);
return {
id: config.id,
name: config.name,
provider: connectionProvider?.providerType ?? "unknown",
provider: resolved?.providerType ?? "unknown",
bucketName: config.bucketName,
prefix: config.prefix,
endpoint: connectionProvider?.endpoint ?? undefined,
region: connectionProvider?.region,
endpoint: resolved?.endpoint ?? undefined,
region: resolved?.region,
};
}

Expand Down
Loading
Loading