Skip to content

Add private Sites and owner access - #247

Merged
vigneshrajsb merged 12 commits into
mainfrom
feat/sites-access-control
Sep 22, 2026
Merged

vigneshrajsb merged 12 commits into
mainfrom
feat/sites-access-control

Conversation

@vigneshrajsb

@vigneshrajsb vigneshrajsb commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Adds private-by-default Sites for users, with owner checks across the API, gateway, and MCP. Existing Sites and service-key uploads stay public. A Site keeps its ID and content URL when visibility changes.

Migration 035 preserves existing Site rows and supports an intentional full downgrade that makes active Sites public. Stop old writers and gateways before starting the new core image.

Validation: 309 focused tests, server typecheck, lint, and an isolated PostgreSQL migration up/down check passed. Live private-open retest remains pending.

Companion PRs: UI · CLI · Helm · Docs

Use the established UI URL, authentication setting and Sites configuration instead of private-
site-specific settings.

@vigneshrajsb vigneshrajsb left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inline walkthrough of the production changes for V1 Sites access control.

email: identity.email,
preferredUsername: identity.preferredUsername,
displayName: identity.displayName,
issuer: identity.issuer ?? null,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New personal keys retain the verified issuer with the subject. Site ownership must not depend on a mutable email or username.

const input = body as Record<string, unknown>;
const visibility = readSiteVisibility(input.visibility);
const revision = readSiteRevision(input.expectedAccessRevision, true)!;
const site = await new SitesService().setVisibility((await params).siteId, visibility, principal, revision);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Visibility has its own owner-authorized mutation. Requiring the access revision prevents an old page from overwriting a newer publish or privacy decision.

try {
const upload = await readUploadFile(req);
const service = new SitesService();
const existing = await service.getSite(routeParams.siteId, principal);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Check access before reading the upload body, then let the service recheck ownership and revisions when it commits the replacement.

try {
const service = new SitesService();
const site = await service.extendSite(routeParams.siteId);
const site = await service.extendSite(

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Extension now uses the caller identity and optional access revision, so only the owner can extend the current Site state.

try {
const service = new SitesService();
const site = await service.getSite(routeParams.siteId);
const site = await service.getSite(routeParams.siteId, principal);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The single-Site endpoint now applies the same private metadata boundary as listing: only the owner can retrieve a private Site.

}

/** RFC 7662, authenticated as this confidential client; no Admin API role expansion. */
async introspectAccessToken(token: string): Promise<boolean> {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The existing confidential Keycloak client checks whether a user access token is still active; no new bridge credential is introduced.

}
}

async getOAuthTokenStatus(token: string): Promise<'active' | 'revoked' | 'unknown'> {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An unknown identity-provider response fails Site access closed rather than treating an unverified token as active.

this.assertEnabled(config);
const principal = input.principal;
const machine = principal.kind === 'service_key';
const visibility = input.visibility ?? (machine ? 'public' : 'private');

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

User and personal-key uploads default to private; service-key uploads create public Sites for automation workflows.

Comment thread src/shared/openApiSpec.ts
},
},
schemas: {
SitesBrowserMintRequest: {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The published API contract includes the browser ticket request and the new Site visibility, revision, and capability fields consumed by clients.

Comment thread ws-server.ts
res.end(statusCode === 404 ? 'not found' : 'internal server error');
return true;
}
await handleSitesRequest(req, res, service);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The gateway delegates Site traffic to the authorization-aware handler before any content is streamed.

Some deployments can't put Sites content on a separate registrable
domain from the UI. Add SITES_ALLOW_SHARED_APEX to bypass that check
per-deployment, paired with a tighter gateway CSP (connect-src/form-action
'self') so hosted content can't reach elsewhere on the shared apex.
Denied-owner responses now return a distinct 403 with a clear message
instead of collapsing into the same 404 used for missing/deleted Sites.
This is an internal tool, so anti-enumeration protection is traded for
clarity: a denied teammate now knows to ask the owner for access rather
than assuming the link is broken.
…hange

Missed in the earlier 404->403 pass: a service-key-owned site's nonowner
delete, and the test that asserted missing/private deletion errors were
identical (now intentionally distinct: 404 for missing, 403 for denied).
Replacing content or toggling visibility no longer bumps a site to the
top of the list; the list now reflects when a site was first created.
@vigneshrajsb
vigneshrajsb marked this pull request as ready for review September 22, 2026 19:49
@vigneshrajsb
vigneshrajsb requested a review from a team as a code owner September 22, 2026 19:49
@vigneshrajsb
vigneshrajsb merged commit e50c29f into main Sep 22, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants