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
11 changes: 11 additions & 0 deletions module/SOLUTION_REGISTRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,17 @@ place only: the gateway's `ext_authz`, read through
`lib/auth-session.ts` decodes an access token without verifying it, so an
organization taken from there is one the caller chose.

`GET /api/solutions/<id>/installation` is the third projection, and the one
a solution PAGE consumes. It answers the verified viewer organization's active
installation of the routed solution (`{installationId, healthy}`), through the same
`entitledSolutions` join as the menu, and `404` for a solution that is not
registered or not entitled to the viewer alike. The solution outlet reads it with
the viewer's session and hands the id to the page as `SolutionBinding.installationId`.
A page never names an installation itself: one it chose could be another
organization's. The gateway's `/solutions/_entitlements` entries carry
`installationId` for this; the authority already returned it and the gateway used
to drop it.

The authority answer is `SolutionEntitlementService.ListSolutionEntitlements`,
which reads the installation set and the scope grant + share union in **one**
transaction, so the two can never describe different moments. The join key is the
Expand Down
13 changes: 10 additions & 3 deletions module/services/auth-gateway/code/gateway_solution_entitlements.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,12 @@ type solutionEntitlementEntry struct {
// The boundary that admitted it, so a consumer can say WHICH grant made a
// solution visible without a second authority call.
ScopeNodeID string `json:"scopeNodeId"`
// The organization's active installation of this target, the one the grant
// above was issued in. A solution acting in it (an agent module binds every
// turn to one) needs it handed to the page by the host: resolved here from
// the verified viewer, never named by the page, which could otherwise name
// another organization's installation.
InstallationID string `json:"installationId"`
}

// handleSolutionEntitlements answers what the calling viewer may use.
Expand Down Expand Up @@ -294,9 +300,10 @@ func (g *Gateway) collectSolutionEntitlements(ctx context.Context, org, viewer s
}
for _, entitlement := range resp.GetEntitlements() {
entries = append(entries, solutionEntitlementEntry{
TargetID: entitlement.GetTargetId(),
Healthy: entitlement.GetHealthy(),
ScopeNodeID: entitlement.GetRootScopeNodeId(),
TargetID: entitlement.GetTargetId(),
Healthy: entitlement.GetHealthy(),
ScopeNodeID: entitlement.GetRootScopeNodeId(),
InstallationID: entitlement.GetInstallationId(),
})
}
token = resp.GetNextPageToken()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ func TestGatewaySolutionEntitlements_AsksAboutTheVerifiedIdentity(t *testing.T)
require.Equal(t, body.Viewer, authority.calls[0].GetSubjectId())
require.NotEmpty(t, body.Org)
require.Equal(t, []solutionEntitlementEntry{
{TargetID: fakeSolutionTarget("audit"), Healthy: true, ScopeNodeID: "node-1"},
{TargetID: fakeSolutionTarget("audit"), Healthy: true, ScopeNodeID: "node-1", InstallationID: "install-1"},
}, body.Usable)
}

Expand Down
8 changes: 4 additions & 4 deletions module/services/frontend/code/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions module/services/frontend/code/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@
"@codefly-dev/saas-plugin-contract": "2.4.2",
"@codefly-dev/saas-plugin-react": "0.4.3",
"@codefly-dev/saas-sdk": "0.3.17",
"@codefly-dev/saas-ui": "0.12.5",
"@codefly-dev/ui": "0.12.5",
"@codefly-dev/saas-ui": "0.12.6",
"@codefly-dev/ui": "0.12.6",
"@codefly/saas-plugin-manifest": "0.1.1",
"@codefly/saas-settings": "0.1.0",
"@connectrpc/connect": "^2.1.1",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@codefly-dev/ui",
"version": "0.12.5",
"version": "0.12.6",
"description": "The shared, versioned Codefly SaaS frontend kit: the plugin host and the tokens-as-data skin mechanism, standalone so the host and Module-Federation remotes import and dedupe one copy.",
"repository": {
"type": "git",
Expand Down
7 changes: 7 additions & 0 deletions module/services/frontend/code/packages/saas-ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ components:
Feed an entry to `<DeclaredSourceCard>`. Optional, and absent from an older
host: treat absence as "the host does not tell me", never as "this solution
declares nothing".
- `installationId?: string` — the viewer organization's active installation of
this solution, resolved by the host from the verified viewer's entitlements for
the routed solution target (`GET /api/solutions/<id>/installation`). A remote
acting in an installation (an agent turn is bound to one) reads it here and
never asks a person for one. Undefined while resolving, on an older host and
when the viewer is not entitled: treat absence as "no installation to act in",
never substitute a value.
- `solutionFetch(binding, path, init)` / `solutionJson<T>(binding, path, init)` —
a request to the solution's own backend at `apiBase + path`, same-origin,
through the host's `authedFetch` (refresh-then-retry on a 401) with the bearer
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@codefly-dev/saas-ui",
"version": "0.12.5",
"version": "0.12.6",
"description": "Reusable SaaS-domain frontend components (datasources, …) the portal and solutions both import, built on @codefly-dev/ui primitives and driven by an injected typed client.",
"repository": {
"type": "git",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,19 @@ export interface SolutionBinding {
* nothing".
*/
declaredSources?: DeclaredSource[];
/**
* The viewer organization's active installation of this solution, as the
* host resolved it from the VERIFIED viewer's entitlements for the solution
* target this page is routed to. A remote acting in an installation (an
* agent turn is bound to one) reads it here; it never asks a person for one
* and never names one itself, because a page-chosen installation could be
* another organization's.
*
* Optional, and undefined while the host is still resolving it, on an older
* host, and when the viewer is not entitled: a remote must treat absence as
* "no installation to act in", never fall back to a value of its own.
*/
installationId?: string;
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

vi.mock("server-only", () => ({}));

const { getWorkspaceSecret, getEndpoints } = vi.hoisted(() => ({
getWorkspaceSecret: vi.fn<(name: string, key: string) => string | undefined>(),
getEndpoints: vi.fn<() => Array<Record<string, unknown>>>(() => []),
}));
vi.mock("codefly", () => ({ getWorkspaceSecret, getEndpoints }));

import { GET } from "@/app/api/solutions/[id]/installation/route";

const GATEWAY = "http://gateway.internal:8080";

function manifest(id: string): string {
return JSON.stringify({
id,
nav: { title: id.toUpperCase(), path: `/s/${id}` },
frontend: { type: "module-federation", manifestUrl: `https://${id}.internal/mf-manifest.json`, exposedModule: "./Page" },
});
}

/** The registry, and the gateway-verified entitlement read for the viewer. */
function gatewayServing(registered: string[], entitled: Array<{ id: string; installationId?: string; healthy?: boolean }>) {
return vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
const path = new URL(input instanceof Request ? input.url : String(input)).pathname;
if (path === "/solutions/_entitlements") {
if (!new Headers(init?.headers).get("authorization")) {
return new Response(JSON.stringify({ error: "unauthenticated" }), { status: 401 });
}
return Response.json({
org: "org-acme",
viewer: "viewer-1",
solutions: entitled.map((entry) => ({
targetId: `target-${entry.id}`,
healthy: entry.healthy ?? true,
scopeNodeId: `node-${entry.id}`,
...(entry.installationId === undefined ? {} : { installationId: entry.installationId }),
})),
});
}
if (path === "/solutions/_registry") {
return Response.json({
revision: 1,
solutions: registered.map((id) => ({ id, status: "active", targetId: `target-${id}`, manifest: manifest(id) })),
});
}
return new Response("unexpected", { status: 500 });
});
}

function resetRegistryCache() {
const g = globalThis as Record<string, unknown>;
g.__solutionSnapshot = null;
g.__solutionSnapshotInFlight = null;
g.__solutionProjectionCache = new Map();
}

function call(id: string, credential = "Bearer viewer-token") {
const headers = new Headers();
if (credential) headers.set("authorization", credential);
return GET(new Request(`http://frontend/api/solutions/${id}/installation`, { headers }), { params: Promise.resolve({ id }) });
}

describe("solution installation route", () => {
beforeEach(() => {
resetRegistryCache();
getWorkspaceSecret.mockReturnValue("internal-test-token");
getEndpoints.mockReturnValue([{ service: "auth-gateway", name: "rest", address: `${GATEWAY}/rest` }]);
});
afterEach(() => {
getWorkspaceSecret.mockReset();
vi.unstubAllGlobals();
resetRegistryCache();
});

it("answers the viewer organization's installation of the routed target", async () => {
vi.stubGlobal("fetch", gatewayServing(["solution-a", "solution-b"], [
{ id: "solution-a", installationId: "inst-a" },
{ id: "solution-b", installationId: "inst-b" },
]));
const res = await call("solution-a");
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ installationId: "inst-a", healthy: true });
});

it("answers 404 for a registered solution the viewer is not entitled to", async () => {
vi.stubGlobal("fetch", gatewayServing(["solution-a", "solution-b"], [{ id: "solution-b", installationId: "inst-b" }]));
const res = await call("solution-a");
expect(res.status).toBe(404);
expect(await res.json()).not.toHaveProperty("installationId");
});

it("answers 404 for an unregistered solution, as for an unentitled one", async () => {
vi.stubGlobal("fetch", gatewayServing(["solution-b"], [{ id: "solution-b", installationId: "inst-b" }]));
expect((await call("solution-a")).status).toBe(404);
});

it("answers 404 when an older gateway does not say which installation", async () => {
vi.stubGlobal("fetch", gatewayServing(["solution-a"], [{ id: "solution-a" }]));
expect((await call("solution-a")).status).toBe(404);
});

it("refuses a viewer the gateway did not verify instead of answering", async () => {
vi.stubGlobal("fetch", gatewayServing(["solution-a"], [{ id: "solution-a", installationId: "inst-a" }]));
const res = await call("solution-a", "");
expect(res.status).not.toBe(200);
expect(await res.text()).not.toContain("inst-a");
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { entitlementFailureResponse, isEntitlementFailure, viewerEntitlements } from "@/solutions/entitlements";
import { entitledSolutions } from "@/solutions/projections";
import { loadSolutionsWithRevision } from "@/solutions/registry";

export const dynamic = "force-dynamic";
export const runtime = "nodejs";

interface RouteContext {
params: Promise<{ id: string }>;
}

/**
* The verified viewer organization's installation of ONE solution, for the
* outlet to hand the solution page (`SolutionBinding.installationId`).
*
* It is a projection, so it answers from the gateway-verified viewer's
* entitlements and nothing the browser said: the route alias in the path only
* selects the registered record, whose immutable TARGET is the join key — the
* same join the navigation makes (projections.ts `entitledSolutions`). An alias
* is reusable and a target is not, so a replacement binding never inherits the
* predecessor's installation. At most one active installation exists per
* (organization, target), so the join has at most one answer.
*
* A viewer the gateway does not entitle to the target gets 404, the same as a
* solution that is not registered: neither discloses that the other exists.
*/
export async function GET(request: Request, context: RouteContext): Promise<Response> {
const { id } = await context.params;
const entitlements = await viewerEntitlements(request);
if (isEntitlementFailure(entitlements)) {
return entitlementFailureResponse(entitlements);
}
const registered = await loadSolutionsWithRevision();
if (registered === "unavailable") {
return Response.json({ error: "registry_unavailable" }, { status: 503 });
}
// The navigation's own join, over the whole registry, so this answer and the
// menu can never disagree about which solutions the viewer may use.
const entitlement = entitledSolutions(registered.solutions, entitlements).find(({ manifest }) => manifest.id === id)?.entitlement;
if (!entitlement || entitlement.installationId === "") {
return Response.json({ error: "not_installed" }, { status: 404, headers: { "cache-control": "no-store" } });
}
return Response.json(
{ installationId: entitlement.installationId, healthy: entitlement.healthy },
{ headers: { "cache-control": "no-store" } },
);
}
32 changes: 32 additions & 0 deletions module/services/frontend/code/src/solutions/SolutionOutlet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ import {
lazy,
type ReactNode,
Suspense,
useEffect,
useState,
useSyncExternalStore,
} from "react";
import * as ReactJSXRuntime from "react/jsx-runtime";
Expand Down Expand Up @@ -441,6 +443,34 @@ function useInBrowser(): boolean {

const LOADING = <div className="p-6 text-sm opacity-70">Loading solution…</div>;

/**
* The viewer organization's installation of this solution, read from the host's
* own projection route, which resolves it from the gateway-verified viewer's
* entitlements for the routed target. Undefined until it answers, and on any
* refusal: the remote then has no installation to act in, which is the honest
* state, rather than one this outlet guessed.
*/
function useSolutionInstallation(solutionId: string | null): string | undefined {
const [resolved, setResolved] = useState<{ solutionId: string; installationId: string } | null>(null);
useEffect(() => {
if (!solutionId) return;
let live = true;
authedFetch(`/api/solutions/${encodeURIComponent(solutionId)}/installation`, { cache: "no-store" })
.then(async (response) => {
if (!response.ok) return;
const body = (await response.json()) as { installationId?: unknown };
if (live && typeof body.installationId === "string" && body.installationId !== "") {
setResolved({ solutionId, installationId: body.installationId });
}
})
.catch(() => {});
return () => {
live = false;
};
}, [solutionId]);
return resolved && resolved.solutionId === solutionId ? resolved.installationId : undefined;
}

export function SolutionOutlet({
remote,
pageProps,
Expand All @@ -461,6 +491,7 @@ export function SolutionOutlet({
}) {
const inBrowser = useInBrowser();
const Remote = inBrowser ? remoteComponent(remote) : null;
const installationId = useSolutionInstallation(inBrowser ? pageProps.solutionId : null);

return (
<SolutionErrorBoundary key={remote.id}>
Expand All @@ -473,6 +504,7 @@ export function SolutionOutlet({
subscribeToken={subscribeToken}
refreshAccessToken={refreshToken}
authedFetch={authedFetch}
installationId={installationId}
dashboardAuthoring={authoring}
/>
) : (
Expand Down
Loading
Loading