mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
734715f461 | ||
|
|
b290c1d922 | ||
|
|
dd068e31d0 | ||
|
|
90cffbfcde | ||
|
|
25acf14c8b | ||
|
|
be781c5f1b | ||
|
|
4cb0730875 | ||
|
|
00b2fcdea3 | ||
|
|
f70347b579 | ||
|
|
3d9691b11a | ||
|
|
c2fe813929 | ||
|
|
ef7fc05dcc | ||
|
|
1c2d33f149 | ||
|
|
b1fdc8d665 | ||
|
|
1ecc23cf83 | ||
|
|
7230580b18 | ||
|
|
1460751b41 | ||
|
|
4f96d371f4 | ||
|
|
c5cf1dc390 | ||
|
|
503a450b40 | ||
|
|
f4dfbd735b | ||
|
|
81402918d2 | ||
|
|
32c950de5b | ||
|
|
792361507e | ||
|
|
4f267fb83e |
No files matched your search
+35
-39
@@ -6,33 +6,33 @@ We at **Puter** are committed to maintaining a secure experience for our users a
|
||||
|
||||
The following are in scope for this program:
|
||||
|
||||
- **The Puter open-source project** (available at [github.com/HeyPuter/puter](https://github.com/HeyPuter/puter))
|
||||
- **`puter.com`**
|
||||
- **`api.puter.com`**
|
||||
* **The Puter open-source project** (available at [github.com/HeyPuter/puter](https://github.com/HeyPuter/puter))
|
||||
* **`puter.com`**
|
||||
* **`api.puter.com`**
|
||||
|
||||
Out-of-scope:
|
||||
|
||||
- Third-party services, applications, or libraries not maintained by Puter.
|
||||
- Social engineering attacks (e.g., phishing against staff).
|
||||
- Denial of Service (DoS), spam, or volumetric attacks.
|
||||
- Physical security issues.
|
||||
* Third-party services, applications, or libraries not maintained by Puter.
|
||||
* Social engineering attacks (e.g., phishing against staff).
|
||||
* Denial of Service (DoS), spam, or volumetric attacks.
|
||||
* Physical security issues.
|
||||
|
||||
## Known Non-Issues (Please Check Before Submitting)
|
||||
|
||||
The following have already been reviewed and determined **not to be vulnerabilities**. Reports that only re-describe one of these are **not eligible for a reward** and will be closed as non-issues — even if they include new code references. Please review this list before submitting:
|
||||
|
||||
- **XSS / CORS / token issues scoped to `api.puter.com`.** The primary user session cookie lives on `puter.com`, not on the API origin, so reflected/stored XSS, CORS, or token handling on `api.puter.com` is generally out of scope. Two exceptions we _do_ evaluate on their merits — please report these: (a) attacker-controlled content served **inline** (e.g. an HTML `Content-Type`) from API file/response endpoints, and (b) anything that abuses the app-scoped `puter_token_v2` companion cookie set on the API host.
|
||||
- **SSRF via `secureFetch`.** Production routes outbound requests through an isolated proxy that has no access to internal/SSRF-sensitive resources.
|
||||
- **Attacks that depend on guessing an `appInstanceID` or app UID.** These are random 128-bit secret values and are not considered guessable.
|
||||
- **Apps invoking drivers, creating workers, or using KV.** Applications are intended to do this; worker permissions are scoped to the owning app. This is by design.
|
||||
- **App metadata or app user-count "leaks".** This information is currently public by design.
|
||||
- **General "token in a URL" / token-lifetime designs** — signed directory URLs exposing children, a write signature implying read, or app tokens outliving a web session. These are current intended behaviors.
|
||||
- **Missing PKCE, unverified `id_token` signatures, or other OIDC hardening.** Provider tokens are obtained through a server-to-server authorization-code exchange with the provider's token endpoint over TLS, so the resulting `id_token` / userinfo claims are trusted from that channel rather than from local JWKS signature verification — the callback never accepts a caller-supplied `id_token`. Adding local signature / `aud` / `iss` / `exp` checks is welcome defense-in-depth (please open a GitHub issue/PR), but their absence is not an account-takeover vector on its own.
|
||||
- **JWT "algorithm confusion" / unpinned `algorithms` in `jwt.verify`.** Puter's session tokens are HMAC-signed (HS256) with a server-side secret, and there is no asymmetric public key anywhere in the verification path, so `alg`-substitution attacks do not apply. Explicitly pinning `algorithms` is a fine hardening PR, but it is not a vulnerability.
|
||||
- **Static-source "SQL injection" in internal pagination.** Findings such as `LIMIT ${limit}` in list/notification queries: the limit is numerically coerced and clamped before it reaches the query, so it is not reachable with attacker-controlled string input. Hardening PRs to the internal stores are welcome, but these are not exploitable as reported.
|
||||
- **Deprecated `saveTo*` GUI app messages.** The legacy `saveToDesktop` / `saveToDocuments` / etc. app-IPC handlers can create — never overwrite — new files in standard user folders. The behavior is non-destructive, path-traversal-safe, and deprecated (slated for removal); it is not treated as a sandbox escape.
|
||||
- **Best-practice suggestions** such as login/registration username enumeration (kept intentionally for UX) or unauthenticated unsubscribe links (industry norm).
|
||||
- **Rate-limiting suggestions for TURN credential issuance** (intentional; not billed per tunnel).
|
||||
* **XSS / CORS / token issues scoped to `api.puter.com`.** The primary user session cookie lives on `puter.com`, not on the API origin, so reflected/stored XSS, CORS, or token handling on `api.puter.com` is generally out of scope. Two exceptions we *do* evaluate on their merits — please report these: (a) attacker-controlled content served **inline** (e.g. an HTML `Content-Type`) from API file/response endpoints, and (b) anything that abuses the app-scoped `puter_token_v2` companion cookie set on the API host.
|
||||
* **SSRF via `secureFetch`.** Production routes outbound requests through an isolated proxy that has no access to internal/SSRF-sensitive resources.
|
||||
* **Attacks that depend on guessing an `appInstanceID` or app UID.** These are random 128-bit secret values and are not considered guessable.
|
||||
* **Apps invoking drivers, creating workers, or using KV.** Applications are intended to do this; worker permissions are scoped to the owning app. This is by design.
|
||||
* **App metadata or app user-count "leaks".** This information is currently public by design.
|
||||
* **General "token in a URL" / token-lifetime designs** — signed directory URLs exposing children, a write signature implying read, or app tokens outliving a web session. These are current intended behaviors.
|
||||
* **Missing PKCE, unverified `id_token` signatures, or other OIDC hardening.** Provider tokens are obtained through a server-to-server authorization-code exchange with the provider's token endpoint over TLS, so the resulting `id_token` / userinfo claims are trusted from that channel rather than from local JWKS signature verification — the callback never accepts a caller-supplied `id_token`. Adding local signature / `aud` / `iss` / `exp` checks is welcome defense-in-depth (please open a GitHub issue/PR), but their absence is not an account-takeover vector on its own.
|
||||
* **JWT "algorithm confusion" / unpinned `algorithms` in `jwt.verify`.** Puter's session tokens are HMAC-signed (HS256) with a server-side secret, and there is no asymmetric public key anywhere in the verification path, so `alg`-substitution attacks do not apply. Explicitly pinning `algorithms` is a fine hardening PR, but it is not a vulnerability.
|
||||
* **Static-source "SQL injection" in internal pagination.** Findings such as `LIMIT ${limit}` in list/notification queries: the limit is numerically coerced and clamped before it reaches the query, so it is not reachable with attacker-controlled string input. Hardening PRs to the internal stores are welcome, but these are not exploitable as reported.
|
||||
* **Deprecated `saveTo*` GUI app messages.** The legacy `saveToDesktop` / `saveToDocuments` / etc. app-IPC handlers can create — never overwrite — new files in standard user folders. The behavior is non-destructive, path-traversal-safe, and deprecated (slated for removal); it is not treated as a sandbox escape.
|
||||
* **Best-practice suggestions** such as login/registration username enumeration (kept intentionally for UX) or unauthenticated unsubscribe links (industry norm).
|
||||
* **Rate-limiting suggestions for TURN credential issuance** (intentional; not billed per tunnel).
|
||||
|
||||
If you believe you have a **genuinely new** exploit chain that defeats one of these rationales (for example, demonstrating a sensitive credential that really is reachable on `api.puter.com`), say so explicitly and show why the reasoning above does not apply.
|
||||
|
||||
@@ -40,7 +40,7 @@ If you believe you have a **genuinely new** exploit chain that defeats one of th
|
||||
|
||||
To participate, you must:
|
||||
|
||||
1. **Report responsibly**: Provide detailed steps to reproduce the issue, including proof-of-concept code, screenshots, or a screen recording (see _Proof of Reproduction_ below).
|
||||
1. **Report responsibly**: Provide detailed steps to reproduce the issue, including proof-of-concept code, screenshots, or a screen recording (see *Proof of Reproduction* below).
|
||||
2. **Do no harm**: Do not exfiltrate, modify, or delete data. Only access your own account or test data.
|
||||
3. **Respect availability**: Do not perform denial-of-service attacks or automated scans that degrade service.
|
||||
4. **Follow disclosure policy**: Do not publicly disclose vulnerabilities until we have confirmed and patched the issue.
|
||||
@@ -51,30 +51,26 @@ Reports that do not meet these guidelines may not be eligible for a reward.
|
||||
|
||||
## Proof of Reproduction
|
||||
|
||||
Reports **MUST** demonstrate a **working, reproducible exploit with real impact** — not a theoretical or static-source-review finding. Please include:
|
||||
Reports must demonstrate a **working, reproducible exploit with real impact** — not a theoretical or static-source-review finding. Please include:
|
||||
|
||||
- Exact steps to reproduce, the relevant request/response or code path, and the commit or version you tested.
|
||||
- The **observed** result versus the **expected** result.
|
||||
- For client-side, UI, or authentication-flow bugs: a short screen recording (≤ 2 minutes) showing the exploit working end-to-end on a real Puter instance.
|
||||
- For server-side bugs: a runnable proof-of-concept and screenshot or video showing the attack process.
|
||||
* Exact steps to reproduce, the relevant request/response or code path, and the commit or version you tested.
|
||||
* The **observed** result versus the **expected** result.
|
||||
* For client-side, UI, or authentication-flow bugs: a short screen recording (≤ 2 minutes) showing the exploit working end-to-end on a real Puter instance.
|
||||
* For server-side bugs: a runnable proof-of-concept.
|
||||
|
||||
Reports based solely on reading the source ("source review only, not tested") or unverified AI/LLM-generated reports are the **lowest triage priority and not eligible for bounty**.
|
||||
If you used an AI tool to help find an issue, you must personally verify that it actually reproduces before submitting.
|
||||
Reports based solely on reading the source ("source review only, not tested") or unverified AI/LLM-generated reports are the **lowest triage priority and are generally not eligible**. If you used an AI tool to help find an issue, you must personally verify that it actually reproduces before submitting.
|
||||
|
||||
Please submit **one issue per report**.
|
||||
Bundled "audit packs" of many speculative findings will not be elligible for bounty; send each confirmed issue separately.
|
||||
Additionally please **DO NOT** submit too many issues at once; these will be treated as a bundled report, and will not be elligible for bounty.
|
||||
Please submit **one issue per report**. Bundled "audit packs" of many speculative findings will be declined; send each confirmed issue separately.
|
||||
|
||||
## Reporting Process
|
||||
|
||||
To report a vulnerability, email us at: **[[email protected]](mailto:[email protected])**.
|
||||
Include:
|
||||
|
||||
- A description of the vulnerability
|
||||
- Steps to reproduce
|
||||
- Potential impact
|
||||
- Suggested remediation (if available)
|
||||
- Paypal id where we can send the bounty (if eligible)
|
||||
* A description of the vulnerability
|
||||
* Steps to reproduce
|
||||
* Potential impact
|
||||
* Suggested remediation (if available)
|
||||
|
||||
We aim to acknowledge receipt within **72 hours** and provide a resolution timeline.
|
||||
|
||||
@@ -82,10 +78,10 @@ We aim to acknowledge receipt within **72 hours** and provide a resolution timel
|
||||
|
||||
We offer monetary rewards based on the severity of the vulnerability, as determined by our internal assessment (using CVSS as a guide).
|
||||
|
||||
- **Critical: \$1,000 – \$2,000**
|
||||
- **High: \$500 – \$1,000**
|
||||
- **Medium: \$200 – \$500**
|
||||
- **Low: \$50 – \$100**
|
||||
* **Critical: \$1,000 – \$2,000**
|
||||
* **High: \$500 – \$1,000**
|
||||
* **Medium: \$200 – \$500**
|
||||
* **Low: \$50 – \$100**
|
||||
|
||||
Non-security issues, suggestions, and best practices feedback are always welcome, but may not qualify for a reward.
|
||||
If multiple researchers report the same issue, the bounty will be awarded to the first eligible report we receive.
|
||||
|
||||
@@ -206,84 +206,6 @@ describe('whoami extension — handleWhoami', () => {
|
||||
expect(JSON.stringify(body)).not.toContain('bootstrap-secret');
|
||||
});
|
||||
|
||||
it('never exposes phone, card fingerprint or signup identity', async () => {
|
||||
const user = await seedUser();
|
||||
await server.stores.user.update(user.id as number, {
|
||||
phone: '+15551234567',
|
||||
card_fingerprint: 'fp_ABC123',
|
||||
requires_phone_verification: false,
|
||||
requires_card_verification: false,
|
||||
});
|
||||
// Guard against a vacuous assertion below: the columns really do hold
|
||||
// the values we then expect never to see on the wire.
|
||||
const stored = await server.stores.user.getById(user.id as number, {
|
||||
cached: false,
|
||||
force: true,
|
||||
});
|
||||
expect(stored?.phone).toBe('+15551234567');
|
||||
expect(stored?.card_fingerprint).toBe('fp_ABC123');
|
||||
|
||||
for (const actor of [
|
||||
{ user: { uuid: user.uuid, id: user.id as number } },
|
||||
{
|
||||
user: { uuid: user.uuid, id: user.id as number },
|
||||
app: { uid: 'app-test-actor' },
|
||||
},
|
||||
]) {
|
||||
const { res, captured } = makeRes();
|
||||
await runWithContext({ actor }, () =>
|
||||
handleWhoami(makeReq(), res),
|
||||
);
|
||||
|
||||
const body = captured.body as Record<string, unknown>;
|
||||
expect(body.phone).toBeUndefined();
|
||||
expect(body.card_fingerprint).toBeUndefined();
|
||||
expect(body.password).toBeUndefined();
|
||||
expect(body.otp_secret).toBeUndefined();
|
||||
expect(body.signup_ip).toBeUndefined();
|
||||
// Not just absent as a key — the values must not appear anywhere
|
||||
// in the payload (nested under metadata, taskbar items, …).
|
||||
const serialized = JSON.stringify(body);
|
||||
expect(serialized).not.toContain('5551234567');
|
||||
expect(serialized).not.toContain('fp_ABC123');
|
||||
// The verification flags, which the GUI acts on, still ship.
|
||||
expect(body).toHaveProperty('requires_phone_verification');
|
||||
expect(body).toHaveProperty('requires_card_verification');
|
||||
}
|
||||
});
|
||||
|
||||
it('scrubs sensitive keys added to metadata, and leaves the cached row intact', async () => {
|
||||
const user = await seedUser();
|
||||
await server.stores.user.updateMetadata(user.id as number, {
|
||||
tmp_password: 'bootstrap-secret',
|
||||
billing: { card_fingerprint: 'fp_NESTED', tier: 'pro' },
|
||||
});
|
||||
|
||||
const { res, captured } = makeRes();
|
||||
await runWithContext(
|
||||
{ actor: { user: { uuid: user.uuid, id: user.id as number } } },
|
||||
() => handleWhoami(makeReq(), res),
|
||||
);
|
||||
|
||||
const body = captured.body as Record<string, unknown>;
|
||||
const metadata = body.metadata as Record<string, unknown>;
|
||||
expect(metadata.tmp_password).toBeUndefined();
|
||||
// Nested sensitive keys are removed too; siblings survive.
|
||||
expect(metadata.billing).toEqual({ tier: 'pro' });
|
||||
expect(JSON.stringify(body)).not.toContain('fp_NESTED');
|
||||
|
||||
// The scrub works on a copy — server-side state is untouched.
|
||||
const fresh = await server.stores.user.getById(user.id as number, {
|
||||
cached: false,
|
||||
force: true,
|
||||
});
|
||||
expect(fresh?.metadata?.tmp_password).toBe('bootstrap-secret');
|
||||
expect(
|
||||
(fresh?.metadata?.billing as Record<string, unknown>)
|
||||
?.card_fingerprint,
|
||||
).toBe('fp_NESTED');
|
||||
});
|
||||
|
||||
it('marks the user as oidc_only when password is null', async () => {
|
||||
const slug = Math.random().toString(36).slice(2, 8);
|
||||
const oidcUser = await server.stores.user.create({
|
||||
|
||||
+3
-74
@@ -33,68 +33,6 @@ const CLIENT_VISIBLE_FEATURE_FLAGS: ReadonlySet<string> = new Set([
|
||||
'prompt_user_when_navigation_away_from_puter',
|
||||
]);
|
||||
|
||||
// Keys that must never leave the server, whoever put them on the response.
|
||||
// `details` is an explicit pick, but the `whoami.details` event hands
|
||||
// listeners the full UserRow next to the object they may write to, and
|
||||
// `metadata` is a free-form blob — so any of these can arrive on the
|
||||
// response without an edit to the pick above. The scrub runs last, over the
|
||||
// whole payload, and is the one place that decides what "sensitive" means.
|
||||
//
|
||||
// Credentials and single-use tokens, the payment/phone identifiers used for
|
||||
// verification (`card_fingerprint` is the Stripe fingerprint, stable per
|
||||
// card number), the network identity recorded at signup, and internal
|
||||
// anti-abuse bookkeeping. `requires_phone_verification` /
|
||||
// `requires_card_verification` stay: they are the flags the GUI acts on, and
|
||||
// they carry no identifier.
|
||||
const SENSITIVE_KEYS: ReadonlySet<string> = new Set([
|
||||
'password',
|
||||
'tmp_password',
|
||||
'pass_recovery_token',
|
||||
'email_confirm_code',
|
||||
'email_confirm_token',
|
||||
'change_email_confirm_token',
|
||||
'otp_secret',
|
||||
'otp_recovery_codes',
|
||||
'card_fingerprint',
|
||||
'phone',
|
||||
'clean_email',
|
||||
'signup_ip',
|
||||
'signup_ip_forwarded',
|
||||
'signup_user_agent',
|
||||
'signup_origin',
|
||||
'signup_server',
|
||||
'audit_metadata',
|
||||
]);
|
||||
|
||||
// Depth-limited, cycle-safe walk deleting every SENSITIVE_KEYS entry it finds
|
||||
// at any level (`metadata` and `taskbar_items` are both nested structures).
|
||||
const scrubSensitive = (
|
||||
value: unknown,
|
||||
seen: Set<object> = new Set(),
|
||||
depth = 0,
|
||||
): void => {
|
||||
if (depth > 8 || value === null || typeof value !== 'object') return;
|
||||
if (seen.has(value as object)) return;
|
||||
seen.add(value as object);
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) scrubSensitive(entry, seen, depth + 1);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const key of Object.keys(value as Record<string, unknown>)) {
|
||||
if (SENSITIVE_KEYS.has(key)) {
|
||||
delete (value as Record<string, unknown>)[key];
|
||||
continue;
|
||||
}
|
||||
scrubSensitive(
|
||||
(value as Record<string, unknown>)[key],
|
||||
seen,
|
||||
depth + 1,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const handleWhoami = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
@@ -136,12 +74,8 @@ export const handleWhoami = async (
|
||||
}
|
||||
}
|
||||
|
||||
// Deep-copied (it is decoded JSON) so the scrub below edits the response
|
||||
// and not the cached UserRow. Sensitive keys inside it — tmp_password and
|
||||
// anything else on the denylist — are removed by scrubSensitive.
|
||||
const metadata = user.metadata
|
||||
? structuredClone(user.metadata)
|
||||
: user.metadata;
|
||||
const metadata = user.metadata ? { ...user.metadata } : user.metadata;
|
||||
if (metadata) delete metadata.tmp_password;
|
||||
|
||||
const details: Record<string, unknown> = {
|
||||
username: user.username,
|
||||
@@ -150,9 +84,7 @@ export const handleWhoami = async (
|
||||
unconfirmed_email: user.email,
|
||||
email_confirmed: user.email_confirmed || user.username === 'admin',
|
||||
requires_email_confirmation: user.requires_email_confirmation,
|
||||
// The phone number itself is deliberately absent: nothing on the
|
||||
// client reads it, and it is PII that would otherwise be handed to
|
||||
// every app actor. Only the verification flag ships.
|
||||
phone: user.phone,
|
||||
requires_phone_verification: user.requires_phone_verification,
|
||||
requires_card_verification: user.requires_card_verification,
|
||||
desktop_bg_url: user.desktop_bg_url,
|
||||
@@ -264,9 +196,6 @@ export const handleWhoami = async (
|
||||
delete subscription.offering.price_id;
|
||||
}
|
||||
|
||||
// Last word on what ships, after every listener has had its say.
|
||||
scrubSensitive(details);
|
||||
|
||||
res.json(details);
|
||||
};
|
||||
|
||||
|
||||
@@ -1101,7 +1101,8 @@ export class AuthController extends PuterController {
|
||||
is_temp: user!.password === null && user!.email === null,
|
||||
ip:
|
||||
(req?.headers?.['x-forwarded-for'] as
|
||||
string | undefined) ||
|
||||
| string
|
||||
| undefined) ||
|
||||
(
|
||||
req as unknown as {
|
||||
connection?: { remoteAddress?: string };
|
||||
|
||||
@@ -212,30 +212,6 @@ const makeRes = (): MockRes => {
|
||||
return res;
|
||||
};
|
||||
|
||||
/**
|
||||
* Run the call route and return the rejection, or null when it resolves.
|
||||
* Lets a test assert on *which* gate rejected without depending on how far
|
||||
* an admitted call gets afterwards.
|
||||
*/
|
||||
const callForError = async (
|
||||
routes: Captured,
|
||||
req: Request,
|
||||
actor: Actor,
|
||||
): Promise<{ statusCode?: number; legacyCode?: string } | null> => {
|
||||
try {
|
||||
await runWithContext({ actor }, () =>
|
||||
routes['POST /call'](
|
||||
req,
|
||||
makeRes() as unknown as Response,
|
||||
() => {},
|
||||
),
|
||||
);
|
||||
return null;
|
||||
} catch (e) {
|
||||
return e as { statusCode?: number; legacyCode?: string };
|
||||
}
|
||||
};
|
||||
|
||||
const makeReq = (body: Record<string, unknown> = {}, actor?: Actor): Request =>
|
||||
({
|
||||
body,
|
||||
@@ -362,7 +338,8 @@ describe('DriverController.#handleCall (via captured router)', () => {
|
||||
it('admits a bare session ("root" token) actor on the AI drivers', async () => {
|
||||
// Privileged ("godmode") apps call AI with the user's own session
|
||||
// token, so the AI drivers no longer set `noUserSession`: the
|
||||
// session actor must get past the credential-shape gate.
|
||||
// session actor reaches the permission scan (403 forbidden — no
|
||||
// perms granted) instead of the credential-shape rejection.
|
||||
const actor = await makeUserActor();
|
||||
const req = makeReq(
|
||||
{
|
||||
@@ -372,8 +349,18 @@ describe('DriverController.#handleCall (via captured router)', () => {
|
||||
},
|
||||
actor,
|
||||
);
|
||||
const err = await callForError(routes, req, actor);
|
||||
expect(err?.legacyCode).not.toBe('app_or_api_token_required');
|
||||
await expect(
|
||||
runWithContext({ actor }, () =>
|
||||
routes['POST /call'](
|
||||
req,
|
||||
makeRes() as unknown as Response,
|
||||
() => {},
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
legacyCode: 'forbidden',
|
||||
});
|
||||
});
|
||||
|
||||
it('admits app and access-token actors on the AI drivers (they fail later on permission, not credential shape)', async () => {
|
||||
@@ -398,15 +385,29 @@ describe('DriverController.#handleCall (via captured router)', () => {
|
||||
},
|
||||
actor,
|
||||
);
|
||||
const err = await callForError(routes, req, actor);
|
||||
expect(err?.legacyCode).not.toBe('app_or_api_token_required');
|
||||
await expect(
|
||||
runWithContext({ actor }, () =>
|
||||
routes['POST /call'](
|
||||
req,
|
||||
makeRes() as unknown as Response,
|
||||
() => {},
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
// Fresh actors hold no service:* perms, so the call still
|
||||
// 403s — but at the permission scan, proving the
|
||||
// credential-shape gate let the delegated actor through.
|
||||
legacyCode: 'forbidden',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('admits a user-scoped worker session on the AI drivers', async () => {
|
||||
// Workers deployed without an app binding authenticate as a user
|
||||
// actor whose session row is kind='worker' — they must keep their
|
||||
// AI access rather than be rejected for their credential shape.
|
||||
// AI access (they fail later on permission here, not on
|
||||
// credential shape).
|
||||
const base = await makeUserActor();
|
||||
const actor: Actor = {
|
||||
...base,
|
||||
@@ -420,13 +421,24 @@ describe('DriverController.#handleCall (via captured router)', () => {
|
||||
},
|
||||
actor,
|
||||
);
|
||||
const err = await callForError(routes, req, actor);
|
||||
expect(err?.legacyCode).not.toBe('app_or_api_token_required');
|
||||
await expect(
|
||||
runWithContext({ actor }, () =>
|
||||
routes['POST /call'](
|
||||
req,
|
||||
makeRes() as unknown as Response,
|
||||
() => {},
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
legacyCode: 'forbidden',
|
||||
});
|
||||
});
|
||||
|
||||
it('still allows bare session actors on drivers without the noUserSession flag', async () => {
|
||||
// puter-kvstore doesn't set the flag, so the session actor must get
|
||||
// past the credential-shape gate.
|
||||
// puter-kvstore doesn't set the flag; the session actor reaches
|
||||
// the permission scan (403 forbidden — no perms granted) instead
|
||||
// of being rejected for its credential shape.
|
||||
const actor = await makeUserActor();
|
||||
const req = makeReq(
|
||||
{
|
||||
@@ -436,24 +448,24 @@ describe('DriverController.#handleCall (via captured router)', () => {
|
||||
},
|
||||
actor,
|
||||
);
|
||||
const err = await callForError(routes, req, actor);
|
||||
expect(err?.legacyCode).not.toBe('app_or_api_token_required');
|
||||
await expect(
|
||||
runWithContext({ actor }, () =>
|
||||
routes['POST /call'](
|
||||
req,
|
||||
makeRes() as unknown as Response,
|
||||
() => {},
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
statusCode: 403,
|
||||
legacyCode: 'forbidden',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects with 403 when the actor lacks the service permission', async () => {
|
||||
// A scoped access token holds only the permissions written to its
|
||||
// own row, so it is the actor shape that can still be denied — the
|
||||
// `driver`/`service` grants every user inherits are resolved from
|
||||
// the issuer only for full-access tokens.
|
||||
const base = await makeUserActor();
|
||||
const actor: Actor = {
|
||||
...base,
|
||||
accessToken: {
|
||||
uid: `tok-${uuidv4()}`,
|
||||
issuer: base,
|
||||
fullAccess: false,
|
||||
},
|
||||
};
|
||||
// A fresh user has no service:* perms → permission.check returns
|
||||
// false → 403.
|
||||
const actor = await makeUserActor();
|
||||
const req = makeReq(
|
||||
{
|
||||
interface: 'puter-kvstore',
|
||||
@@ -462,8 +474,15 @@ describe('DriverController.#handleCall (via captured router)', () => {
|
||||
},
|
||||
actor,
|
||||
);
|
||||
const err = await callForError(routes, req, actor);
|
||||
expect(err).toMatchObject({ statusCode: 403 });
|
||||
await expect(
|
||||
runWithContext({ actor }, () =>
|
||||
routes['POST /call'](
|
||||
req,
|
||||
makeRes() as unknown as Response,
|
||||
() => {},
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
});
|
||||
|
||||
it('returns the wrapped {success, result, service} envelope on a successful call', async () => {
|
||||
|
||||
@@ -297,7 +297,8 @@ export class DriverController extends PuterController {
|
||||
|
||||
if (req.actor) {
|
||||
const permService = this.services.permission as unknown as
|
||||
PermissionService | undefined;
|
||||
| PermissionService
|
||||
| undefined;
|
||||
if (permService) {
|
||||
// Build via PermissionUtil.join so any `:` in a driver or
|
||||
// interface name is escaped — raw interpolation would let a
|
||||
|
||||
@@ -64,7 +64,8 @@ export class NotificationController extends PuterController {
|
||||
});
|
||||
|
||||
const notifService = this.services.notification as unknown as
|
||||
NotificationService | undefined;
|
||||
| NotificationService
|
||||
| undefined;
|
||||
if (notifService?.markAcknowledged) {
|
||||
await notifService.markAcknowledged(uid, userId);
|
||||
} else {
|
||||
@@ -114,7 +115,8 @@ export class NotificationController extends PuterController {
|
||||
});
|
||||
|
||||
const notifService = this.services.notification as unknown as
|
||||
NotificationService | undefined;
|
||||
| NotificationService
|
||||
| undefined;
|
||||
if (notifService?.markShown) {
|
||||
await notifService.markShown(uid, userId);
|
||||
} else {
|
||||
|
||||
@@ -574,7 +574,8 @@ export class PuterAIController extends PuterController {
|
||||
text: extractTextContent(
|
||||
(
|
||||
messageResult.message as
|
||||
Record<string, unknown> | undefined
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
)?.content,
|
||||
),
|
||||
index: 0,
|
||||
|
||||
@@ -88,22 +88,55 @@ const implicit_user_app_permissions = [
|
||||
},
|
||||
];
|
||||
|
||||
// Permissions every user actor holds, regardless of group membership.
|
||||
//
|
||||
// Roots only: a grant subsumes everything beneath it, so `service` already
|
||||
// answers `service:puter-kvstore:ii:puter-kvstore`. Listing descendants adds
|
||||
// entries no check can ever need.
|
||||
//
|
||||
// A floor, not a ceiling — anything that depends on *who* the user is belongs
|
||||
// in the ACL as a `user_to_group_permissions` / `user_to_user_permissions`
|
||||
// row. Adding a root here grants it to every user, temp accounts included.
|
||||
const default_user_permissions = {
|
||||
driver: {},
|
||||
service: {},
|
||||
// Pre-v2 each entry below carried a `policy: { 'rate-limit': { max, period } }`
|
||||
// block attached by a `policyPerm('temp.kv')` / `policyPerm('user.es')` helper,
|
||||
// and a `driverPolicies` table held the actual `{max, period}` values. Nothing
|
||||
// ever read those policy blocks in v2 — rate limiting is now declared
|
||||
// directly on each driver via `@Driver({ rateLimit })` / `readonly rateLimit`
|
||||
// (see KVStoreDriver, AppDriver, SubdomainDriver, NotificationDriver), with
|
||||
// per-subscription overrides via `bySubscription`. The grants themselves
|
||||
// stay so the permission scan still emits a path for each group/permission;
|
||||
// only the dead policy plumbing was removed.
|
||||
const hardcoded_user_group_permissions = {
|
||||
system: {
|
||||
'ca342a5e-b13d-4dee-9048-58b11a57cc55': {
|
||||
driver: {},
|
||||
service: {},
|
||||
feature: {},
|
||||
'local-terminal:access': {},
|
||||
},
|
||||
'b7220104-7905-4985-b996-649fdcdb3c8f': {
|
||||
driver: {},
|
||||
service: {},
|
||||
'service:hello-world:ii:hello-world': {},
|
||||
'service:puter-kvstore:ii:puter-kvstore': {},
|
||||
'driver:puter-kvstore': {},
|
||||
'service:puter-notifications:ii:crud-q': {},
|
||||
'service:puter-apps:ii:crud-q': {},
|
||||
'service:puter-subdomains:ii:crud-q': {},
|
||||
'service:apps:ii:crud-q': {},
|
||||
'service:es\\Cnotification:ii:crud-q': {},
|
||||
'service:es\\Capp:ii:crud-q': {},
|
||||
'service:app:ii:crud-q': {},
|
||||
'service:es\\Csubdomain:ii:crud-q': {},
|
||||
},
|
||||
'78b1b1dd-c959-44d2-b02c-8735671f9997': {
|
||||
driver: {},
|
||||
service: {},
|
||||
'service:hello-world:ii:hello-world': {},
|
||||
'service:puter-kvstore:ii:puter-kvstore': {},
|
||||
'driver:puter-kvstore': {},
|
||||
'service:es\\Cnotification:ii:crud-q': {},
|
||||
'service:es\\Capp:ii:crud-q': {},
|
||||
'service:app:ii:crud-q': {},
|
||||
'service:es\\Csubdomain:ii:crud-q': {},
|
||||
'service:apps:ii:crud-q': {},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
implicit_user_app_permissions,
|
||||
default_implicit_user_app_permissions,
|
||||
default_user_permissions,
|
||||
hardcoded_user_group_permissions,
|
||||
};
|
||||
@@ -222,14 +222,14 @@ describe('PermissionService (integration)', () => {
|
||||
const { actor } = await makeUserActor();
|
||||
const allowed = await permService.check(
|
||||
actor,
|
||||
`zztest:nope-${uuidv4()}:ii:read`,
|
||||
`service:nope-${uuidv4()}:ii:read`,
|
||||
);
|
||||
expect(allowed).toBeFalsy();
|
||||
});
|
||||
|
||||
it('canManagePermission delegates to check on manage:<perm>', async () => {
|
||||
const { user, actor } = await makeUserActor();
|
||||
const perm = `zztest:manage-test-${uuidv4()}:ii:read`;
|
||||
const perm = `service:manage-test-${uuidv4()}:ii:read`;
|
||||
// Grant manage:<perm> via the flat store.
|
||||
await server.stores.permission.setFlatUserPerm(
|
||||
user.id,
|
||||
@@ -253,7 +253,7 @@ describe('PermissionService (integration)', () => {
|
||||
permService.grantUserUserPermission(
|
||||
actor,
|
||||
`does-not-exist-${uuidv4()}`,
|
||||
'zztest:foo:ii:read',
|
||||
'service:foo:ii:read',
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
@@ -264,7 +264,7 @@ describe('PermissionService (integration)', () => {
|
||||
permService.grantUserUserPermission(
|
||||
actor,
|
||||
user.username,
|
||||
'zztest:foo:ii:read',
|
||||
'service:foo:ii:read',
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 400 });
|
||||
});
|
||||
@@ -277,7 +277,7 @@ describe('PermissionService (integration)', () => {
|
||||
permService.grantUserUserPermission(
|
||||
issuer,
|
||||
target.username,
|
||||
`zztest:unmanaged-${uuidv4()}:ii:read`,
|
||||
`service:unmanaged-${uuidv4()}:ii:read`,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
@@ -286,7 +286,7 @@ describe('PermissionService (integration)', () => {
|
||||
it('grant persists when issuer holds manage:<permission>', async () => {
|
||||
const { user: issuer, actor: issuerActor } = await makeUserActor();
|
||||
const { user: target, actor: targetActor } = await makeUserActor();
|
||||
const permission = `zztest:user-user-${uuidv4()}:ii:read`;
|
||||
const permission = `service:user-user-${uuidv4()}:ii:read`;
|
||||
await server.stores.permission.setFlatUserPerm(
|
||||
issuer.id,
|
||||
`manage:${permission}`,
|
||||
@@ -316,7 +316,7 @@ describe('PermissionService (integration)', () => {
|
||||
permService.revokeUserUserPermission(
|
||||
actor,
|
||||
`does-not-exist-${uuidv4()}`,
|
||||
'zztest:foo:ii:read',
|
||||
'service:foo:ii:read',
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
});
|
||||
@@ -329,7 +329,7 @@ describe('PermissionService (integration)', () => {
|
||||
permService.revokeUserUserPermission(
|
||||
issuer,
|
||||
target.username,
|
||||
`zztest:unmanaged-${uuidv4()}:ii:read`,
|
||||
`service:unmanaged-${uuidv4()}:ii:read`,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
@@ -359,7 +359,7 @@ describe('PermissionService (integration)', () => {
|
||||
permService.grantUserAppPermission(
|
||||
actor,
|
||||
`does-not-exist-${uuidv4()}`,
|
||||
'zztest:foo:ii:read',
|
||||
'service:foo:ii:read',
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
@@ -368,7 +368,7 @@ describe('PermissionService (integration)', () => {
|
||||
it('persists a user→app grant and is idempotent', async () => {
|
||||
const { user, actor } = await makeUserActor();
|
||||
const app = await makeApp(user.id);
|
||||
const permission = `zztest:gua-${uuidv4()}:ii:read`;
|
||||
const permission = `service:gua-${uuidv4()}:ii:read`;
|
||||
|
||||
await runWithContext({ actor }, () =>
|
||||
permService.grantUserAppPermission(actor, app.uid, permission),
|
||||
@@ -389,7 +389,7 @@ describe('PermissionService (integration)', () => {
|
||||
it('revokeUserAppPermission removes the row', async () => {
|
||||
const { user, actor } = await makeUserActor();
|
||||
const app = await makeApp(user.id);
|
||||
const permission = `zztest:rua-${uuidv4()}:ii:read`;
|
||||
const permission = `service:rua-${uuidv4()}:ii:read`;
|
||||
await runWithContext({ actor }, () =>
|
||||
permService.grantUserAppPermission(actor, app.uid, permission),
|
||||
);
|
||||
@@ -417,7 +417,7 @@ describe('PermissionService (integration)', () => {
|
||||
permService.revokeUserAppPermission(
|
||||
appActor,
|
||||
app.uid,
|
||||
'zztest:foo:ii:read',
|
||||
'service:foo:ii:read',
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
});
|
||||
@@ -448,8 +448,8 @@ describe('PermissionService (integration)', () => {
|
||||
const { user, actor } = await makeUserActor();
|
||||
const app = await makeApp(user.id);
|
||||
for (const p of [
|
||||
`zztest:rua-${uuidv4()}:ii:read`,
|
||||
`zztest:rua-${uuidv4()}:ii:write`,
|
||||
`service:rua-${uuidv4()}:ii:read`,
|
||||
`service:rua-${uuidv4()}:ii:write`,
|
||||
]) {
|
||||
await runWithContext({ actor }, () =>
|
||||
permService.grantUserAppPermission(actor, app.uid, p),
|
||||
@@ -488,7 +488,7 @@ describe('PermissionService (integration)', () => {
|
||||
permService.grantDevAppPermission(
|
||||
actor,
|
||||
`does-not-exist-${uuidv4()}`,
|
||||
'zztest:foo:ii:read',
|
||||
'service:foo:ii:read',
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 404 });
|
||||
@@ -502,7 +502,7 @@ describe('PermissionService (integration)', () => {
|
||||
permService.grantDevAppPermission(
|
||||
actor,
|
||||
app.uid,
|
||||
`zztest:unmanaged-${uuidv4()}:ii:read`,
|
||||
`service:unmanaged-${uuidv4()}:ii:read`,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
@@ -511,7 +511,7 @@ describe('PermissionService (integration)', () => {
|
||||
it('grant persists when manage:<perm> is held', async () => {
|
||||
const { user, actor } = await makeUserActor();
|
||||
const app = await makeApp(user.id);
|
||||
const permission = `zztest:dev-${uuidv4()}:ii:read`;
|
||||
const permission = `service:dev-${uuidv4()}:ii:read`;
|
||||
await server.stores.permission.setFlatUserPerm(
|
||||
user.id,
|
||||
`manage:${permission}`,
|
||||
@@ -542,7 +542,7 @@ describe('PermissionService (integration)', () => {
|
||||
permService.revokeDevAppPermission(
|
||||
appActor,
|
||||
app.uid,
|
||||
'zztest:foo:ii:read',
|
||||
'service:foo:ii:read',
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
});
|
||||
@@ -566,7 +566,7 @@ describe('PermissionService (integration)', () => {
|
||||
permService.grantUserGroupPermission(
|
||||
actor,
|
||||
{ id: 1, uid: 'grp-doesnt-matter' },
|
||||
`zztest:unmanaged-${uuidv4()}:ii:read`,
|
||||
`service:unmanaged-${uuidv4()}:ii:read`,
|
||||
),
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
@@ -577,7 +577,7 @@ describe('PermissionService (integration)', () => {
|
||||
permService.revokeUserGroupPermission(
|
||||
{ user: undefined } as unknown as Actor,
|
||||
{ id: 1, uid: 'grp-x' },
|
||||
'zztest:foo:ii:read',
|
||||
'service:foo:ii:read',
|
||||
),
|
||||
).rejects.toMatchObject({ statusCode: 403 });
|
||||
});
|
||||
@@ -587,7 +587,7 @@ describe('PermissionService (integration)', () => {
|
||||
it('listUserPermissionIssuers returns the issuer who granted the target a perm', async () => {
|
||||
const { user: issuer, actor: issuerActor } = await makeUserActor();
|
||||
const { user: target } = await makeUserActor();
|
||||
const permission = `zztest:lst-${uuidv4()}:ii:read`;
|
||||
const permission = `service:lst-${uuidv4()}:ii:read`;
|
||||
await server.stores.permission.setFlatUserPerm(
|
||||
issuer.id,
|
||||
`manage:${permission}`,
|
||||
@@ -666,7 +666,7 @@ describe('PermissionService (integration)', () => {
|
||||
it('revoke is visible immediately — a cached "granted" reading is not served', async () => {
|
||||
const { user: issuer, actor: issuerActor } = await makeUserActor();
|
||||
const { user: target, actor: targetActor } = await makeUserActor();
|
||||
const permission = `zztest:revoke-now-${uuidv4()}:ii:read`;
|
||||
const permission = `service:revoke-now-${uuidv4()}:ii:read`;
|
||||
await grantManage(issuer, permission);
|
||||
|
||||
await runWithContext({ actor: issuerActor }, () =>
|
||||
@@ -721,7 +721,7 @@ describe('PermissionService (integration)', () => {
|
||||
it('grant is visible immediately — a cached "denied" reading is not served', async () => {
|
||||
const { user: issuer, actor: issuerActor } = await makeUserActor();
|
||||
const { user: target, actor: targetActor } = await makeUserActor();
|
||||
const permission = `zztest:grant-now-${uuidv4()}:ii:read`;
|
||||
const permission = `service:grant-now-${uuidv4()}:ii:read`;
|
||||
await grantManage(issuer, permission);
|
||||
|
||||
// Prime a "denied" reading into the cache.
|
||||
@@ -780,7 +780,7 @@ describe('PermissionService (integration)', () => {
|
||||
const { user: issuer, actor: issuerActor } = await makeUserActor();
|
||||
const { user: target, actor: targetActor } = await makeUserActor();
|
||||
const app = await makeApp(target.id);
|
||||
const permission = `zztest:app-revoke-now-${uuidv4()}:ii:read`;
|
||||
const permission = `service:app-revoke-now-${uuidv4()}:ii:read`;
|
||||
await grantManage(issuer, permission);
|
||||
|
||||
await runWithContext({ actor: issuerActor }, () =>
|
||||
@@ -825,7 +825,7 @@ describe('PermissionService (integration)', () => {
|
||||
const { user: issuer, actor: issuerActor } = await makeUserActor();
|
||||
const { user: target, actor: targetActor } = await makeUserActor();
|
||||
const app = await makeApp(target.id);
|
||||
const permission = `zztest:app-grant-now-${uuidv4()}:ii:read`;
|
||||
const permission = `service:app-grant-now-${uuidv4()}:ii:read`;
|
||||
await grantManage(issuer, permission);
|
||||
|
||||
// App is allowed to act with the permission, but the user does
|
||||
@@ -874,7 +874,7 @@ describe('PermissionService (integration)', () => {
|
||||
it('revokeUserUserPermission deletes the linked SQL row before resolving', async () => {
|
||||
const { user: issuer, actor: issuerActor } = await makeUserActor();
|
||||
const { user: target } = await makeUserActor();
|
||||
const permission = `zztest:rvk-sync-${uuidv4()}:ii:read`;
|
||||
const permission = `service:rvk-sync-${uuidv4()}:ii:read`;
|
||||
await grantManage(issuer, permission);
|
||||
await runWithContext({ actor: issuerActor }, () =>
|
||||
permService.grantUserUserPermission(
|
||||
@@ -906,7 +906,7 @@ describe('PermissionService (integration)', () => {
|
||||
it('revokeUserUserPermission surfaces a failed SQL delete instead of swallowing it', async () => {
|
||||
const { user: issuer, actor: issuerActor } = await makeUserActor();
|
||||
const { user: target } = await makeUserActor();
|
||||
const permission = `zztest:rvk-fail-${uuidv4()}:ii:read`;
|
||||
const permission = `service:rvk-fail-${uuidv4()}:ii:read`;
|
||||
await grantManage(issuer, permission);
|
||||
await runWithContext({ actor: issuerActor }, () =>
|
||||
permService.grantUserUserPermission(
|
||||
@@ -946,7 +946,7 @@ describe('PermissionService (integration)', () => {
|
||||
it('scan-path warms of the flat view carry an expiry (grants are permanent)', async () => {
|
||||
const { user: issuer, actor: issuerActor } = await makeUserActor();
|
||||
const { user: target, actor: targetActor } = await makeUserActor();
|
||||
const permission = `zztest:warm-ttl-${uuidv4()}:ii:read`;
|
||||
const permission = `service:warm-ttl-${uuidv4()}:ii:read`;
|
||||
await grantManage(issuer, permission);
|
||||
// The linked (SQL) path is a delegation chain: it only grants
|
||||
// if the issuer holds the permission themselves. Give the
|
||||
@@ -1088,78 +1088,47 @@ describe('PermissionService — scan paths', () => {
|
||||
{ ownerUserId },
|
||||
);
|
||||
|
||||
describe('default user permissions and group grants', () => {
|
||||
it('grants the default permissions to a member of the default user group', async () => {
|
||||
describe('group-derived grants', () => {
|
||||
it('members of the default user group inherit the system driver grants', async () => {
|
||||
const { actor } = await makeGroupedUser();
|
||||
expect(await permService.check(actor, 'driver:puter-kvstore')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
await permService.check(
|
||||
actor,
|
||||
'service:puter-kvstore:ii:puter-kvstore',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('grants the default permissions to a user in no group at all', async () => {
|
||||
// The floor does not depend on membership, which is what repairs
|
||||
// the account whose best-effort group insert failed at signup —
|
||||
// previously locked out of every driver call with no recovery.
|
||||
it('a user in no group inherits nothing', async () => {
|
||||
const { actor } = await makeLooseUser();
|
||||
expect(await permService.check(actor, 'driver:puter-kvstore')).toBe(
|
||||
true,
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
await permService.check(
|
||||
actor,
|
||||
'service:puter-kvstore:ii:puter-kvstore',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('grants the default permissions to a temp user', async () => {
|
||||
it('registerSystemGrantForUsers reaches the user group but not the temp group', async () => {
|
||||
const permission = `feature:users-only-${uuidv4()}`;
|
||||
const { actor: member } = await makeGroupedUser();
|
||||
const { row: temp, actor: tempActor } = await makeLooseUser();
|
||||
await server.stores.group.addUsers(DEFAULT_TEMP_GROUP_UID, [
|
||||
temp.username,
|
||||
]);
|
||||
expect(
|
||||
await permService.check(tempActor, 'driver:puter-kvstore'),
|
||||
).toBe(true);
|
||||
|
||||
permService.registerSystemGrantForUsers(permission);
|
||||
|
||||
expect(await permService.check(member, permission)).toBe(true);
|
||||
expect(await permService.check(tempActor, permission)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not grant a permission outside the default set', async () => {
|
||||
// Both used to be admin-only entries in the group-keyed map.
|
||||
// Nothing grants them now.
|
||||
const { actor } = await makeLooseUser();
|
||||
expect(await permService.check(actor, 'local-terminal:access')).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
await permService.check(actor, `feature:${uuidv4()}`),
|
||||
).toBe(false);
|
||||
});
|
||||
it('registerSystemGrantForEveryone reaches temp users too', async () => {
|
||||
const permission = `feature:everyone-${uuidv4()}`;
|
||||
const { row: temp, actor: tempActor } = await makeLooseUser();
|
||||
await server.stores.group.addUsers(DEFAULT_TEMP_GROUP_UID, [
|
||||
temp.username,
|
||||
]);
|
||||
|
||||
it('never queries group membership to resolve a user permission', async () => {
|
||||
// The membership lookup existed only to re-derive the flattened
|
||||
// constant above, so no scan should reach for it now.
|
||||
const { actor } = await makeGroupedUser();
|
||||
const spy = vi.spyOn(server.stores.group, 'listGroupsWithMember');
|
||||
try {
|
||||
expect(
|
||||
await permService.check(actor, 'driver:puter-kvstore', {
|
||||
noCache: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
await permService.check(actor, `zztest:${uuidv4()}:read`, {
|
||||
noCache: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
permService.registerSystemGrantForEveryone(permission, {
|
||||
note: 'test',
|
||||
});
|
||||
|
||||
expect(await permService.check(tempActor, permission)).toBe(true);
|
||||
});
|
||||
|
||||
it('honours a group grant issued by a user, and drops it on revoke', async () => {
|
||||
@@ -1403,22 +1372,6 @@ describe('PermissionService — scan paths', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('a scoped token does not inherit the default user permissions', async () => {
|
||||
// The floor applies to user actors only. A token must still carry
|
||||
// its own row, or a scoped token would silently widen to every
|
||||
// driver the moment its issuer held the `driver` root.
|
||||
const { actor } = await makeGroupedUser();
|
||||
expect(await permService.check(actor, 'driver:puter-kvstore')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
await permService.check(
|
||||
scopedToken(actor, `tok-${uuidv4()}`),
|
||||
'driver:puter-kvstore',
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('a scoped token resolves a permission it carries and its issuer holds', async () => {
|
||||
const { row, actor } = await makeGroupedUser();
|
||||
const permission = `zztest:tok-${uuidv4()}:ii:read`;
|
||||
@@ -1778,53 +1731,3 @@ describe('PermissionService — scan paths', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('PermissionService — default user permissions vs. group config', () => {
|
||||
/** A user with no group membership, on an arbitrary server. */
|
||||
const makeLooseActor = async (srv: PuterServer): Promise<Actor> => {
|
||||
const username = `pdg${Math.random().toString(36).slice(2, 10)}`;
|
||||
const u = await srv.stores.user.create({
|
||||
username,
|
||||
uuid: uuidv4(),
|
||||
password: null,
|
||||
email: `${username}@test.local`,
|
||||
requires_email_confirmation: false,
|
||||
});
|
||||
return {
|
||||
user: {
|
||||
id: u.id,
|
||||
uuid: u.uuid,
|
||||
username: u.username,
|
||||
email: u.email ?? null,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
it('grants the default permissions with no default group configured', async () => {
|
||||
// Self-hosted deployments may run without default groups. The floor
|
||||
// is not group-derived, so it applies regardless — a deployment that
|
||||
// clears both no longer has every driver call fail closed.
|
||||
const bare = await setupTestServer({
|
||||
default_user_group: '',
|
||||
default_temp_group: '',
|
||||
} as never);
|
||||
try {
|
||||
const perms = bare.services
|
||||
.permission as unknown as PermissionService;
|
||||
const actor = await makeLooseActor(bare);
|
||||
expect(await perms.check(actor, 'driver:puter-kvstore')).toBe(true);
|
||||
expect(
|
||||
await perms.check(
|
||||
actor,
|
||||
'service:puter-kvstore:ii:puter-kvstore',
|
||||
),
|
||||
).toBe(true);
|
||||
// Still only the roots the floor names.
|
||||
expect(await perms.check(actor, 'local-terminal:access')).toBe(
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
await bare.shutdown();
|
||||
}
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -41,7 +41,7 @@ import {
|
||||
// @ts-ignore — hardcoded-permissions.js is plain JS
|
||||
import {
|
||||
default_implicit_user_app_permissions,
|
||||
default_user_permissions,
|
||||
hardcoded_user_group_permissions,
|
||||
implicit_user_app_permissions,
|
||||
} from '../../data/hardcoded-permissions.js';
|
||||
import { UserRow } from '../../stores/user/UserStore';
|
||||
@@ -81,6 +81,16 @@ export class PermissionService extends PuterService {
|
||||
private readonly rewriters: PermissionRewriter[] = [];
|
||||
private readonly implicators: PermissionImplicator[] = [];
|
||||
private readonly exploders: PermissionExploder[] = [];
|
||||
/**
|
||||
* System-issued grants registered at runtime by other services. Keyed by
|
||||
* group UID, then by permission string. Merged with the imported
|
||||
* `hardcoded_user_group_permissions.system` map during the hc-user-group
|
||||
* scan.
|
||||
*/
|
||||
private readonly systemGrantsByGroupUid: Record<
|
||||
string,
|
||||
Record<string, unknown>
|
||||
> = {};
|
||||
|
||||
// -- Extension hooks ----------------------------------------------
|
||||
//
|
||||
@@ -100,6 +110,38 @@ export class PermissionService extends PuterService {
|
||||
this.exploders.push(exploder);
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant a permission (as issued by `system`) to everyone — members of both
|
||||
* the default user group and the default temp group.
|
||||
*
|
||||
* Call from an owning service's `onServerStart` (or later).
|
||||
*/
|
||||
registerSystemGrantForEveryone(
|
||||
permission: string,
|
||||
data: unknown = {},
|
||||
): void {
|
||||
const userGroup = this.config.default_user_group;
|
||||
const tempGroup = this.config.default_temp_group;
|
||||
if (userGroup) this.#addSystemGrant(userGroup, permission, data);
|
||||
if (tempGroup) this.#addSystemGrant(tempGroup, permission, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Grant a permission (as issued by `system`) to non-temp users only —
|
||||
* members of the default user group, but not the default temp group.
|
||||
*/
|
||||
registerSystemGrantForUsers(permission: string, data: unknown = {}): void {
|
||||
const userGroup = this.config.default_user_group;
|
||||
if (userGroup) this.#addSystemGrant(userGroup, permission, data);
|
||||
}
|
||||
|
||||
#addSystemGrant(groupUid: string, permission: string, data: unknown): void {
|
||||
if (!this.systemGrantsByGroupUid[groupUid]) {
|
||||
this.systemGrantsByGroupUid[groupUid] = {};
|
||||
}
|
||||
this.systemGrantsByGroupUid[groupUid][permission] = data;
|
||||
}
|
||||
|
||||
// -- Rewrite / explode (pure-ish helpers) ------------------------
|
||||
|
||||
async rewritePermission(permission: string): Promise<string> {
|
||||
@@ -331,38 +373,6 @@ export class PermissionService extends PuterService {
|
||||
}
|
||||
options = exploded.flat();
|
||||
|
||||
// -- default user permissions --
|
||||
// A group-independent floor every user actor holds, resolved in
|
||||
// memory (see `default_user_permissions`). Derived actors are
|
||||
// excluded: an app-under-user is gated by its own implicit grant map
|
||||
// and an access token by its issuer, both of which recurse into a
|
||||
// scan of the user actor and so still see this floor.
|
||||
if (!actor.app && !actor.accessToken && actor.user?.id) {
|
||||
const granted = options.find((option) =>
|
||||
Object.prototype.hasOwnProperty.call(
|
||||
default_user_permissions,
|
||||
option,
|
||||
),
|
||||
);
|
||||
if (granted !== undefined) {
|
||||
reading.push({
|
||||
$: 'option',
|
||||
key: 'default-user-permission',
|
||||
permission: granted,
|
||||
source: 'implied',
|
||||
by: 'default-user-permission',
|
||||
data: (default_user_permissions as Record<string, unknown>)[
|
||||
granted
|
||||
],
|
||||
holder_username: actor.user.username,
|
||||
issuer_username: 'system',
|
||||
});
|
||||
reading.push({ $: 'time', value: Date.now() - startTs });
|
||||
await this.#maybeCacheScan(cacheKey, reading);
|
||||
return reading;
|
||||
}
|
||||
}
|
||||
|
||||
// -- shortcut implicators --
|
||||
let shortCircuit = false;
|
||||
for (const permission of options) {
|
||||
@@ -399,6 +409,7 @@ export class PermissionService extends PuterService {
|
||||
this.#scanNonShortcutImplicators(actor, options, reading),
|
||||
this.#scanAccessToken(actor, options, reading),
|
||||
this.#scanUserUser(actor, options, reading, workingState),
|
||||
this.#scanHcUserGroupUser(actor, options, reading),
|
||||
this.#scanUserGroup(actor, options, reading),
|
||||
this.#scanUserAppImplied(actor, options, reading),
|
||||
this.#scanUserApp(actor, options, reading),
|
||||
@@ -516,6 +527,96 @@ export class PermissionService extends PuterService {
|
||||
reading.push(...subReadings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve permissions that a persistent group's members inherit from an
|
||||
* issuer (typically `system`) via the hardcoded map in
|
||||
* `hardcoded-permissions.js`, merged with any runtime grants registered
|
||||
* through `registerSystemGrantForEveryone` /
|
||||
* `registerSystemGrantForUsers`.
|
||||
*/
|
||||
async #scanHcUserGroupUser(
|
||||
actor: Actor,
|
||||
options: string[],
|
||||
reading: ReadingNode[],
|
||||
): Promise<void> {
|
||||
if (actor.app || actor.accessToken) return;
|
||||
if (!actor.user?.id) return;
|
||||
|
||||
const memberGroups = await this.stores.group.listGroupsWithMember(
|
||||
actor.user.id,
|
||||
);
|
||||
if (memberGroups.length === 0) return;
|
||||
|
||||
const groupByUid: Record<string, { id: number; uid: string }> = {};
|
||||
for (const g of memberGroups) {
|
||||
groupByUid[g.uid] = { id: g.id, uid: g.uid };
|
||||
}
|
||||
|
||||
// Compose the effective issuer → group → permission → data map by
|
||||
// merging the imported hardcoded data with runtime-registered system
|
||||
// grants. Runtime grants are always attributed to the `system` issuer.
|
||||
const hcMap = hardcoded_user_group_permissions as Record<
|
||||
string,
|
||||
Record<string, Record<string, unknown>>
|
||||
>;
|
||||
const hasRuntimeGrants =
|
||||
Object.keys(this.systemGrantsByGroupUid).length > 0;
|
||||
const byIssuer: Record<
|
||||
string,
|
||||
Record<string, Record<string, unknown>>
|
||||
> = hasRuntimeGrants
|
||||
? { ...hcMap, system: { ...(hcMap.system ?? {}) } }
|
||||
: hcMap;
|
||||
if (hasRuntimeGrants) {
|
||||
for (const [gUid, perms] of Object.entries(
|
||||
this.systemGrantsByGroupUid,
|
||||
)) {
|
||||
byIssuer.system[gUid] = {
|
||||
...(byIssuer.system[gUid] ?? {}),
|
||||
...perms,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
for (const issuerUsername of Object.keys(byIssuer)) {
|
||||
const issuerUser =
|
||||
await this.stores.user.getByUsername(issuerUsername);
|
||||
if (!issuerUser) continue;
|
||||
const issuerActor = this.#userToActor(issuerUser);
|
||||
const issuerGroups = byIssuer[issuerUsername];
|
||||
|
||||
for (const groupUid of Object.keys(issuerGroups)) {
|
||||
if (!groupByUid[groupUid]) continue;
|
||||
const issuerGroupPerms = issuerGroups[groupUid];
|
||||
|
||||
for (const permission of options) {
|
||||
if (
|
||||
!Object.prototype.hasOwnProperty.call(
|
||||
issuerGroupPerms,
|
||||
permission,
|
||||
)
|
||||
)
|
||||
continue;
|
||||
const issuerReading = await this.scan(
|
||||
issuerActor,
|
||||
permission,
|
||||
);
|
||||
reading.push({
|
||||
$: 'path',
|
||||
via: 'hc-user-group',
|
||||
has_terminal: readingHasTerminal(issuerReading),
|
||||
permission,
|
||||
data: issuerGroupPerms[permission],
|
||||
holder_username: actor.user.username,
|
||||
issuer_username: issuerUsername,
|
||||
reading: issuerReading,
|
||||
group_id: groupByUid[groupUid].id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #scanUserGroup(
|
||||
actor: Actor,
|
||||
options: string[],
|
||||
|
||||
@@ -298,7 +298,8 @@ export class SocketService extends PuterService {
|
||||
// `{ auth: { ... } }`, not the query string. puter-js uses
|
||||
// `io(url, { auth: { auth_token } })`.
|
||||
const handshakeAuth = socket.handshake.auth as
|
||||
Record<string, unknown> | undefined;
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const tokenRaw =
|
||||
typeof handshakeAuth?.auth_token === 'string'
|
||||
? handshakeAuth.auth_token
|
||||
@@ -621,7 +622,8 @@ export class SocketService extends PuterService {
|
||||
// their next poll of /cache/last-change-timestamp.
|
||||
const originalSocketId = (
|
||||
data.response as
|
||||
{ original_client_socket_id?: string } | undefined
|
||||
| { original_client_socket_id?: string }
|
||||
| undefined
|
||||
)?.original_client_socket_id;
|
||||
await this.send({ room: userId }, 'cache.updated', {
|
||||
timestamp,
|
||||
@@ -635,7 +637,9 @@ export class SocketService extends PuterService {
|
||||
#handleUploadProgress(data: UploadProgressPayload): void {
|
||||
const meta = data.meta ?? {};
|
||||
const userId = (meta.user_id ?? meta.userId) as
|
||||
number | string | undefined;
|
||||
| number
|
||||
| string
|
||||
| undefined;
|
||||
if (!userId) {
|
||||
console.warn('[socket] upload-progress missing user_id', { meta });
|
||||
return;
|
||||
|
||||
@@ -244,9 +244,8 @@ export const createTestUser = async (
|
||||
requires_email_confirmation: false,
|
||||
});
|
||||
|
||||
// Driver permissions no longer come from here (see
|
||||
// `default_user_permissions`), but group-issued ACL grants do, and a
|
||||
// verified signup is a member of the default user group.
|
||||
// Base driver permissions (kv, notifications, …) are system grants on
|
||||
// the default user group — membership is what a verified signup gets.
|
||||
const { default_user_group } = await loadDefaultConfig();
|
||||
if (default_user_group) {
|
||||
await server.stores.group.addUsers(default_user_group, [opts.username]);
|
||||
|
||||
@@ -1737,7 +1737,9 @@ input.myapps-group-name:focus {
|
||||
color: #fff;
|
||||
}
|
||||
@media (hover: hover) {
|
||||
.app-feedback-btn-primary:hover {
|
||||
/* :not(:disabled) matches the base .app-feedback-btn hover rule's
|
||||
specificity, so this later rule wins for primary buttons again. */
|
||||
.app-feedback-btn-primary:hover:not(:disabled) {
|
||||
filter: brightness(1.05);
|
||||
background: linear-gradient(#34a5f8, #088ef0);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user