mirror of
https://github.com/HeyPuter/puter.git
synced 2026-05-04 08:30:39 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user