Shared and private in one portal
Combine an org-shared, RBAC-gated section and a member-keyed private section in one frontend — both drawing down the same org's plan.
Combine an org-shared, RBAC-gated section and a member-keyed private section in one frontend — both drawing down the same org's plan.
Most real portals are both tenancy shapes at once: a shared, org-wide area
that a member's role governs, and a private area that belongs to the
individual member. Farther Shore builds both on the same verified principal, so
you don't choose one model — you place both in one frontend. This recipe wires a
Workspace with a shared, RBAC-gated Reports section and a member-keyed
private Notes section, and shows why the two use different axes.
The three tenancy boundaries map straight onto the two sections:
ctx.principal.subject.memberId and gate nothing — every
member simply sees their own.A single portal renders an org-shared section (visible only to members whose role grants it) alongside a member-private section (each member sees only their own rows) — both enforced at the edge and both billed to one org.
farthershore business rbac enable) for the role gatereports table keyed by org and a notes table keyed by memberThe shared section is org-keyed and role-gated: the data is the whole
org's, and a member sees it only if their role grants reports:read. The private
section is member-keyed and ungated: there is nothing to gate, because
the query already returns only the caller's own rows. Putting them side by side
makes the distinction concrete — and shows that RBAC is opt-in:
you enable it (in the dashboard) for the shared section, while the private
section needs none.
import { PermissionGate } from "@farthershore/farthershore-js/components";
function Workspace() {
return (
<>
{/* SHARED: org-wide data. RBAC (the capability axis) decides who sees it. */}
<PermissionGate permission="reports:read">
<TeamReports />
</PermissionGate>
{/* PRIVATE: member-keyed. No gate — every member sees only their own. */}
<MyNotes />
</>
);
}
<PermissionGate> hides the shared section for members whose role lacks
reports:read. It is a UX convenience — the gateway enforces the same
reports:read permission at the edge — see permission gates.
The private <MyNotes> carries no gate at all.
Declare both routes, and enable RBAC for the business (the Access control
(RBAC) card in the dashboard, or farthershore business rbac enable) so the
platform derives route permissions. The backend keys the shared data on the
org, not the member:
After pushing the preview branch, wait until env list contains its environment
before creating or binding that environment's api row. The production origin
is never a preview fallback. If automatic branch-prefix creation did not occur,
create the preview explicitly first:
farthershore env list <business> --format json
farthershore backend create <business> --env <environment> \
--name "Preview API" --slug api --transport direct \
--idempotency-key <persisted-backend-create-attempt-key> \
--origin-url https://preview-api.example.com --default --format json
farthershore backend list <business> --format json
Filter the structured backend list by the preview's environment id.
The permission groups above explicitly establish reports:read and
notes:read; do not guess permission names from route URLs. Because
RBAC is business-wide, not
per-route: enabling the dashboard flag derives a notes:read permission for the
private route too. Keep the private section universally reachable by granting
notes:read to every role, member included — the "ungated" feel comes from
that universal grant, while the member-keyed query keeps each member's rows
private. The role gate you actually curate is reports:read.
import { fartherShore, requireMember } from "@farthershore/backend";
const fs = fartherShore.initFromEnv();
// SHARED: org-keyed. The edge already enforced the reports:read role gate;
// the data is the whole org's.
app.get(
"/reports",
fs.handler(async (ctx, req, res) => {
const rows = await db.reports.findMany({
where: { orgId: ctx.principal.org.id },
});
res.json(rows);
}),
);
The private route is declared requireMember: true, so the gateway rejects
service-key traffic at the edge (403 member_subject_required). The handler
scopes to the caller's own member id — no role check, because visibility, not
capability, is the axis here:
import { requireMember } from "@farthershore/backend";
// PRIVATE: member-keyed. Same org bill, scoped to the one member.
app.get(
"/notes",
fs.handler(async (ctx, req, res) => {
const me = requireMember(ctx);
const rows = await db.notes.findMany({
where: { orgId: ctx.principal.org.id, memberId: me.memberId },
});
res.json(rows);
}),
);
On the frontend, the private section is just a call through the gateway — the member subject rides the request automatically:
import { useFartherShore } from "@farthershore/farthershore-js/react";
import { useEffect, useState } from "react";
function MyNotes() {
const fs = useFartherShore();
const [notes, setNotes] = useState<unknown[]>([]);
useEffect(() => {
// fs.route.get carries this member's principal; the backend filters by
// ctx.principal.subject.memberId, so the response is already just theirs.
void fs.route.get<unknown[]>("/notes").then(setNotes);
}, [fs]);
return <NotesList notes={notes} />;
}
There is one subscription behind both sections. Every fs.route.* call — shared
or private — meters against the org's plan limits and is attributed to the
calling member (see usage & billing policy). A
member reading their private notes and an admin reading the shared reports both
spend from the same org allowance; the split between shared and private is a
visibility concern, never a billing one.
reports:read sees the Reports section; a
member without it does not (and the gateway denies GET /reports directly).GET /notes called with a service key is denied 403 member_subject_required.<PermissionGate> hides UI only —
confirm RBAC is enabled for the business (farthershore business rbac) and the route's reports:read gate denies at
the edge. UI gating is never the boundary.notes:read is denied permission_denied at the edge before
your member-keyed query runs. Grant notes:read to every role./notes query dropped the
memberId (or the orgId) filter. Member-keyed reads must include both.userExternalId instead of me.memberId; use the Membership id from the
principal.Both sections are your own data and manifest. Revert the route and handler
changes together on a preview branch and rebuild; production is unchanged until
you publish. RBAC itself is a platform flag — farthershore business rbac disable
removes role restrictions in every environment (roles are preserved for
re-enable). Prefer repairing the affected role to expanding access globally.
A data-only mistake (a missing orgId/memberId filter) is
fixed by redeploying the backend, with no contract change.
In this Farther Shore repo, build a
Workspacewith two sections. Shared: enable RBAC for the business withfarthershore business rbac enable(it is a platform setting, not code), declare areportsroute withfs.route, gate its UI with<PermissionGate permission="reports:read">, and key the backend onctx.principal.org.id. Private: declare anotesroute with{ get: { requireMember: true } }, grantnotes:readto every role, and key the backend onrequireMember(ctx).memberId(the Membership id, notuserExternalId). Use thefs.handler(async (ctx, req, res) => …)wrapper on both routes. Build and report preview test steps; do not publish.
import * as fs from "@farthershore/business";
const requests = fs.requests();
const api = fs.backend("api", {
transport: { mode: "direct" },
meters: [requests],
default: true,
});
// SHARED: org-wide data. RBAC derives reports:read from this route.
const reports = fs.route("/reports", {
get: { backend: api, requireMember: true, costs: [requests.fixed(1)] },
});
fs.group("reports", [reports], { permission: {} });
// PRIVATE: member-keyed. requireMember rejects service-key traffic at the edge.
const notes = fs.route("/notes", {
get: { backend: api, requireMember: true, costs: [requests.fixed(1)] },
});
fs.group("notes", [notes], { permission: {} });
fs.plan("team", {
kind: fs.plan.kind.flat,
price: fs.money.usd(49).monthly(),
grants: [reports, notes],
limits: [requests.perMinute(600)],
});
export default fs.business();