feat: add private access rollout gate and auditing (#2560)

Adds a config flag to disable private app gate enforcement, structured middleware audit logs for private access decisions, and regression coverage for the disabled-gate path.
This commit is contained in:
Daniel Salazar
2026-02-27 13:55:15 -08:00
committed by GitHub
parent 866825767b
commit 7e07c3d937
3 changed files with 165 additions and 2 deletions
+1
View File
@@ -73,6 +73,7 @@ config.monitor = {
config.max_subdomains_per_user = 2000;
config.storage_capacity = 1 * 1024 * 1024 * 1024;
config.static_hosting_base_domain_redirect = 'https://developer.puter.com/static-hosting/';
config.enable_private_app_access_gate = true;
// Storage limiting is set to false by default
// Storage available on the mountpoint/drive puter is running is the storage available
@@ -99,6 +99,17 @@ function getPrivateDeniedRedirectUrl (app, denyRedirectUrl) {
return '/';
}
function isPrivateAccessGateEnabled () {
return config.enable_private_app_access_gate !== false;
}
function logPrivateAccessEvent (eventName, fields = {}) {
console.info('private_access', {
eventName,
...fields,
});
}
function getTokenFromAuthorizationHeader (req) {
const authorizationHeader = req.headers?.authorization;
if ( typeof authorizationHeader !== 'string' ) return null;
@@ -172,6 +183,8 @@ async function resolvePrivateIdentity ({ req, services, appUid }) {
const authService = services.get('auth');
const privateCookieName = authService.getPrivateAssetCookieName();
const privateCookieToken = req.cookies?.[privateCookieName];
const hasPrivateCookie = typeof privateCookieToken === 'string' && !!privateCookieToken;
let hasInvalidPrivateCookie = false;
if ( typeof privateCookieToken === 'string' && privateCookieToken ) {
try {
@@ -183,8 +196,11 @@ async function resolvePrivateIdentity ({ req, services, appUid }) {
userUid: claims.userUid,
sessionUuid: claims.sessionUuid,
hasValidPrivateCookie: true,
hasPrivateCookie,
hasInvalidPrivateCookie,
};
} catch {
hasInvalidPrivateCookie = true;
// fallback to next token source
}
}
@@ -199,6 +215,8 @@ async function resolvePrivateIdentity ({ req, services, appUid }) {
source: 'session-cookie',
...identity,
hasValidPrivateCookie: false,
hasPrivateCookie,
hasInvalidPrivateCookie,
};
}
} catch {
@@ -216,6 +234,8 @@ async function resolvePrivateIdentity ({ req, services, appUid }) {
source: 'bootstrap-token',
...identity,
hasValidPrivateCookie: false,
hasPrivateCookie,
hasInvalidPrivateCookie,
};
}
} catch {
@@ -228,6 +248,8 @@ async function resolvePrivateIdentity ({ req, services, appUid }) {
userUid: undefined,
sessionUuid: undefined,
hasValidPrivateCookie: false,
hasPrivateCookie,
hasInvalidPrivateCookie,
};
}
@@ -252,6 +274,14 @@ async function evaluatePrivateAppAccess ({ req, res, services, app, requestPath
try {
await eventService.emit('app.privateAccess.check', accessCheckEvent);
} catch (e) {
logPrivateAccessEvent('private_access.entitlement_check_error', {
appUid: app.uid,
userUid: identity.userUid ?? null,
requestHost: req.hostname,
requestPath,
source: identity.source,
error: e?.message || String(e),
});
console.error('private app access check failed', e);
}
@@ -260,10 +290,22 @@ async function evaluatePrivateAppAccess ({ req, res, services, app, requestPath
app,
accessCheckEvent.result.redirectUrl,
);
logPrivateAccessEvent('private_access.denied', {
appUid: app.uid,
userUid: identity.userUid ?? null,
requestHost: req.hostname,
requestPath,
source: identity.source,
reason: accessCheckEvent.result.reason ?? null,
redirectUrl,
hasPrivateCookie: identity.hasPrivateCookie,
hasInvalidPrivateCookie: identity.hasInvalidPrivateCookie,
});
res.redirect(redirectUrl);
return false;
}
const shouldRefreshPrivateCookie = identity.userUid && !identity.hasValidPrivateCookie;
if ( identity.userUid && !identity.hasValidPrivateCookie ) {
const authService = services.get('auth');
const privateToken = authService.createPrivateAssetToken({
@@ -278,6 +320,16 @@ async function evaluatePrivateAppAccess ({ req, res, services, app, requestPath
);
}
logPrivateAccessEvent('private_access.allowed', {
appUid: app.uid,
userUid: identity.userUid ?? null,
requestHost: req.hostname,
requestPath,
source: identity.source,
cookieRefreshed: !!shouldRefreshPrivateCookie,
hasPrivateCookie: identity.hasPrivateCookie,
hasInvalidPrivateCookie: identity.hasInvalidPrivateCookie,
});
return true;
}
@@ -346,10 +398,21 @@ async function runInternal (req, res, next) {
? await get_app({ id: site.associated_app_id })
: null;
const privateAppEnabled = isPrivateApp(associatedApp);
const privateAccessGateEnabled = isPrivateAccessGateEnabled();
if ( privateAppEnabled && !hostMatchesPrivateDomain(req.hostname) ) {
if (
privateAccessGateEnabled
&& privateAppEnabled
&& !hostMatchesPrivateDomain(req.hostname)
) {
const privateHostRedirect = buildPrivateHostRedirectUrl(req, associatedApp);
if ( privateHostRedirect ) {
logPrivateAccessEvent('private_access.host_redirect', {
appUid: associatedApp?.uid ?? null,
requestHost: req.hostname,
requestPath: req.path,
redirectUrl: privateHostRedirect,
});
return res.redirect(privateHostRedirect);
}
return res.status(403).send('Private app host mismatch');
@@ -407,7 +470,7 @@ async function runInternal (req, res, next) {
req.__puterSiteRootPath = subdomainRootPath;
if ( privateAppEnabled ) {
if ( privateAccessGateEnabled && privateAppEnabled ) {
const accessAllowed = await evaluatePrivateAppAccess({
req,
res,
@@ -33,6 +33,7 @@ vi.mock('../../config.js', () => ({
static_hosting_domain: 'site.puter.localhost',
static_hosting_base_domain_redirect: 'https://developer.puter.com/static-hosting/',
private_app_hosting_domain: 'puter.app',
enable_private_app_access_gate: true,
origin: 'https://puter.com',
cookie_name: 'puter.session.token',
username_regex: /^[a-z0-9_]+$/,
@@ -40,6 +41,7 @@ vi.mock('../../config.js', () => ({
static_hosting_domain: 'site.puter.localhost',
static_hosting_base_domain_redirect: 'https://developer.puter.com/static-hosting/',
private_app_hosting_domain: 'puter.app',
enable_private_app_access_gate: true,
origin: 'https://puter.com',
cookie_name: 'puter.session.token',
username_regex: /^[a-z0-9_]+$/,
@@ -129,6 +131,7 @@ describe('PuterSiteMiddleware', () => {
beforeEach(() => {
vi.clearAllMocks();
config.enable_private_app_access_gate = true;
Context.get = vi.fn().mockReturnValue(mockContextInstance);
getUserMockImpl = async () => null;
getAppMockImpl = async () => null;
@@ -251,6 +254,7 @@ describe('PuterSiteMiddleware', () => {
beforeEach(() => {
vi.clearAllMocks();
config.enable_private_app_access_gate = true;
Context.get = vi.fn().mockReturnValue(mockContextInstance);
getUserMockImpl = async () => null;
getAppMockImpl = async () => null;
@@ -405,5 +409,100 @@ describe('PuterSiteMiddleware', () => {
expect(mockRes.cookie).not.toHaveBeenCalled();
expect(mockNext).not.toHaveBeenCalled();
});
it('skips private app gate when feature flag is disabled', async () => {
config.enable_private_app_access_gate = false;
const eventEmit = vi.fn();
const rootDirectoryNode = {
fetchEntry: vi.fn().mockResolvedValue(undefined),
exists: vi.fn().mockResolvedValue(true),
get: vi.fn().mockImplementation(async (fieldName) => {
if ( fieldName === 'type' ) return 'directory';
if ( fieldName === 'path' ) return '/alice/Public';
return null;
}),
};
const missingFileNode = {
fetchEntry: vi.fn().mockResolvedValue(undefined),
exists: vi.fn().mockResolvedValue(false),
get: vi.fn().mockResolvedValue(null),
};
let filesystemNodeCallCount = 0;
const mockServices = {
get: vi.fn().mockImplementation((serviceName) => {
if ( serviceName === 'puter-site' ) {
return {
get_subdomain: vi.fn().mockResolvedValue({
user_id: 101,
associated_app_id: 202,
root_dir_id: 303,
}),
};
}
if ( serviceName === 'filesystem' ) {
return {
node: vi.fn().mockImplementation(async () => {
filesystemNodeCallCount += 1;
return filesystemNodeCallCount === 1
? rootDirectoryNode
: missingFileNode;
}),
};
}
if ( serviceName === 'acl' ) {
return {
check: vi.fn().mockResolvedValue(true),
};
}
if ( serviceName === 'event' ) return { emit: eventEmit };
return {};
}),
};
mockContextInstance.get.mockImplementation((key) => {
if ( key === 'services' ) return mockServices;
return null;
});
getUserMockImpl = async () => ({ id: 101, suspended: false });
getAppMockImpl = async () => ({
uid: 'app-11111111-1111-1111-1111-111111111111',
name: 'paid-app',
is_private: 1,
index_url: 'https://paid.puter.app/',
});
const mockReq = {
hostname: 'paid.site.puter.localhost',
subdomains: ['paid'],
is_custom_domain: false,
baseUrl: '',
path: '/asset.js',
originalUrl: '/asset.js',
query: {},
cookies: {},
headers: {},
on: vi.fn(),
ctx: mockContextInstance,
};
const mockRes = {
redirect: vi.fn(),
cookie: vi.fn(),
setHeader: vi.fn(),
set: vi.fn().mockReturnThis(),
status: vi.fn().mockReturnThis(),
send: vi.fn(),
write: vi.fn(),
end: vi.fn(),
};
const mockNext = vi.fn();
await capturedMiddleware(mockReq, mockRes, mockNext);
expect(mockRes.redirect).not.toHaveBeenCalled();
expect(eventEmit).not.toHaveBeenCalled();
expect(mockRes.status).toHaveBeenCalledWith(404);
expect(mockNext).not.toHaveBeenCalled();
});
});
});