Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions src/backend/doc/lists-of-things/list-of-permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
will be specified here with `my-name`.
- This permission is always rewritten as the permission
described below (backend does this automatically).

- `<ACCESS-LEVEL>` may be `access`, `read`, or `write` (where `write` implies `read`, and `read` implies `access`).

### `site:uid#<UUID-OF-SITE>:access`
- If the subdomain is **not** [protected](../features/protected-apps.md),
Expand All @@ -41,15 +43,34 @@
allow access to the site via a Puter app iframe with
a token for the entity to which permission was granted

### `app:<NAME-OF-APP>:access`
### `site:owner#<UUID-OF-USER>:<ACCESS-LEVEL>`
- Grants access to **all protected sites owned by the specified user**.
- When checking a specific site permission (`site:uid#...:<ACCESS-LEVEL>`), the system
treats this owner-wide permission as sufficient if the site's owner matches.
- You can also specify the owner by username using
`site:owner@<USERNAME>:<ACCESS-LEVEL>`; it will be rewritten to the canonical
`owner#<UUID>` form automatically.

### `app:<NAME-OF-APP>:<ACCESS-LEVEL>`

- `<NAME-OF-APP>` specifies the app that this
permission is associated with.
- This permission is always rewritten as the permission
described below (backend does this automatically).

### `app:uid#<UUID-OF-APP>:access`
- `<ACCESS-LEVEL>` may be `access`, `read`, or `write` (where `write` implies `read`, and `read` implies `access`).

### `app:uid#<UUID-OF-APP>:<ACCESS-LEVEL>`
- If the app is **not** [protected](../features/protected-apps.md),
this permission is ignored by the system.
- If the app **is** protected, this permission will
allow reading the app's metadata and seeing that the app exists.

### `app:owner#<UUID-OF-USER>:<ACCESS-LEVEL>`
- Grants access to **all protected apps owned by the specified user**.
- When checking a specific app permission (`app:uid#...:<ACCESS-LEVEL>`), the system
treats this owner-wide permission as sufficient if the app's owner matches.
- You can also specify the owner by username using
`app:owner@<USERNAME>:<ACCESS-LEVEL>`; it will be rewritten to the canonical
`owner#<UUID>` form automatically.
- Same access levels apply: `access`, `read`, `write` (`write` ⇒ `read` ⇒ `access`).
64 changes: 62 additions & 2 deletions src/backend/src/modules/apps/ProtectedAppService.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
const { get_app } = require('../../helpers');
const { get_app, get_user } = require('../../helpers');
const { UserActorType } = require('../../services/auth/Actor');
const { PermissionImplicator, PermissionUtil, PermissionRewriter } =
const { PermissionExploder, PermissionImplicator, PermissionUtil, PermissionRewriter } =
require('../../services/auth/permissionUtils.mjs');
const BaseService = require('../../services/BaseService');

Expand All @@ -42,6 +42,21 @@ class ProtectedAppService extends BaseService {
async _init () {
const svc_permission = this.services.get('permission');

// Allow specifying owner by username and rewrite to the canonical UID form
svc_permission.register_rewriter(PermissionRewriter.create({
matcher: permission => {
if ( ! permission.startsWith('app:') ) return false;
const [_, specifier] = PermissionUtil.split(permission);
return specifier.startsWith('owner@');
},
rewriter: async permission => {
const [_1, owner_spec, ...rest] = PermissionUtil.split(permission);
const username = owner_spec.slice('owner@'.length);
const user = await get_user({ username });
return PermissionUtil.join(_1, `owner#${user.uuid ?? user.uid ?? user.id}`, ...rest);
},
}));

svc_permission.register_rewriter(PermissionRewriter.create({
matcher: permission => {
if ( ! permission.startsWith('app:') ) return false;
Expand All @@ -56,6 +71,51 @@ class ProtectedAppService extends BaseService {
},
}));

// Access levels: write > read > access
svc_permission.register_exploder(PermissionExploder.create({
id: 'app-access-levels',
matcher: permission => permission.startsWith('app:'),
exploder: async ({ permission }) => {
const parts = PermissionUtil.split(permission);
if ( parts.length < 3 ) return [permission];

const [prefix, spec, lvl, ...rest] = parts;
const perms = [permission];
if ( lvl === 'access' ) {
perms.push(PermissionUtil.join(prefix, spec, 'read', ...rest));
perms.push(PermissionUtil.join(prefix, spec, 'write', ...rest));
} else if ( lvl === 'read' ) {
perms.push(PermissionUtil.join(prefix, spec, 'write', ...rest));
}
return perms;
},
}));

// Explode a specific app access permission to the owner's wildcard permission
svc_permission.register_exploder(PermissionExploder.create({
id: 'app-owner-wildcard',
matcher: permission => {
if ( ! permission.startsWith('app:') ) return false;
const parts = PermissionUtil.split(permission);
return parts[1]?.startsWith('uid#') && parts[2];
},
exploder: async ({ permission }) => {
const [_1, app_spec, ...rest] = PermissionUtil.split(permission);
const app_uid = app_spec.slice('uid#'.length);
const app = await get_app({ uid: app_uid });
if ( ! app ) return [permission];

const owner = await get_user({ id: app.owner_user_id });
if ( ! owner ) return [permission];

const owner_id = owner.uuid ?? owner.uid ?? owner.id;
return [
permission,
PermissionUtil.join(_1, `owner#${owner_id}`, ...rest),
];
},
}));

// track: object description in comment
// Owner of procted app has implicit permission to access it
svc_permission.register_implicator(PermissionImplicator.create({
Expand Down
65 changes: 64 additions & 1 deletion src/backend/src/services/PuterSiteService.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
*/
const { NodeInternalIDSelector, NodeUIDSelector } = require('../filesystem/node/selectors');
const { SiteActorType } = require('./auth/Actor');
const { PermissionUtil, PermissionRewriter, PermissionImplicator } = require('./auth/permissionUtils.mjs');
const { PermissionExploder, PermissionUtil, PermissionRewriter, PermissionImplicator } = require('./auth/permissionUtils.mjs');
const BaseService = require('./BaseService');
const { DB_WRITE } = require('./database/consts');

Expand Down Expand Up @@ -47,6 +47,21 @@ class PuterSiteService extends BaseService {

// Rewrite site permissions specified by name
const svc_permission = this.services.get('permission');
// owner@username -> owner#uuid
svc_permission.register_rewriter(PermissionRewriter.create({
matcher: permission => {
if ( ! permission.startsWith('site:') ) return false;
const [_, specifier] = PermissionUtil.split(permission);
return specifier.startsWith('owner@');
},
rewriter: async permission => {
const [_1, owner_spec, ...rest] = PermissionUtil.split(permission);
const username = owner_spec.slice('owner@'.length);
const svc_user = services.get('get-user');
const user = await svc_user.get_user({ username });
return PermissionUtil.join(_1, `owner#${user.uuid ?? user.uid ?? user.id}`, ...rest);
},
}));
svc_permission.register_rewriter(PermissionRewriter.create({
matcher: permission => {
if ( ! permission.startsWith('site:') ) return false;
Expand All @@ -61,6 +76,54 @@ class PuterSiteService extends BaseService {
},
}));

// Access levels: write > read > access
svc_permission.register_exploder(PermissionExploder.create({
id: 'site-access-levels',
matcher: permission => permission.startsWith('site:'),
exploder: async ({ permission }) => {
const parts = PermissionUtil.split(permission);
if ( parts.length < 3 ) return [permission];

const [prefix, spec, lvl, ...rest] = parts;
const perms = [permission];
if ( lvl === 'access' ) {
perms.push(PermissionUtil.join(prefix, spec, 'read', ...rest));
perms.push(PermissionUtil.join(prefix, spec, 'write', ...rest));
} else if ( lvl === 'read' ) {
perms.push(PermissionUtil.join(prefix, spec, 'write', ...rest));
}
return perms;
},
}));

// uid#X => owner#Y wildcard
svc_permission.register_exploder(PermissionExploder.create({
id: 'site-owner-wildcard',
matcher: permission => {
if ( ! permission.startsWith('site:') ) return false;
const parts = PermissionUtil.split(permission);
return parts[1]?.startsWith('uid#') && parts[2];
},
exploder: async ({ permission }) => {
const [_1, site_spec, ...rest] = PermissionUtil.split(permission);
const site_uid = site_spec.slice('uid#'.length);
const subdomain = await this.get_subdomain_by_uid(site_uid);
if ( ! subdomain ) return [permission];

const owner_id = subdomain.user_id;
if ( owner_id === null || owner_id === undefined ) return [permission];

const svc_user = services.get('get-user');
const owner = await svc_user.get_user({ id: owner_id });
const owner_key = owner.uuid ?? owner.uid ?? owner.id;

return [
permission,
PermissionUtil.join(_1, `owner#${owner_key}`, ...rest),
];
},
}));

// Imply that sites can read their own files
svc_permission.register_implicator(PermissionImplicator.create({
id: 'in-site',
Expand Down
83 changes: 83 additions & 0 deletions src/gui/src/UI/UIWindowRequestPermission.js
Original file line number Diff line number Diff line change
Expand Up @@ -151,16 +151,99 @@ async function get_permission_description (permission) {
const parts = split_permission(permission);
const [resource_type, resource_id, action, interface_name = null] = parts;
let fsentry;
let app;

if ( resource_type === 'fs' ) {
fsentry = await puter.fs.stat({ uid: resource_id, consistency: 'eventual' });
}
if ( resource_type === 'app' ) {
// resource_id may be uid#<appUid>, owner#<userId>, owner@<username>, or name
const id = resource_id ?? '';
if ( id.startsWith('uid#') ) {
const app_uid = id.slice('uid#'.length);
try {
app = await puter.apps.get(app_uid);
} catch (e) {
// ignore lookup failures; fallback to generic text
}
} else if ( ! id.startsWith('owner#') && ! id.startsWith('owner@') && id ) {
try {
app = await puter.apps.get(id);
} catch (e) {
// ignore lookup failures
}
}
}
if ( resource_type === 'site' ) {
// resource_id may be uid#<siteUid>, owner#<userId>, owner@<username>, or subdomain name
const id = resource_id ?? '';
if ( id.startsWith('uid#') ) {
const site_uid = id.slice('uid#'.length);
try {
// There is no dedicated site getter in puter.js here; fall back to name display
// if available in permission string.
} catch (e) {
// ignore
}
}
}

const permission_mappings = {
'fs': fsentry ? `use ${fsentry.name} located at ${fsentry.dirpath} with ${action} access.` : null,
'thread': action === 'post' ? `post to thread ${resource_id}.` : null,
'service': action === 'ii' ? `use ${resource_id} to invoke ${interface_name}.` : null,
'driver': `use ${resource_id} to ${action}.`,
'app': (() => {
if ( (resource_id ?? '').startsWith('owner#') || (resource_id ?? '').startsWith('owner@') ) {
const label = resource_id.startsWith('owner#')
? resource_id.slice('owner#'.length)
: resource_id.slice('owner@'.length);
if ( action === 'write' ) {
return `modify all protected apps owned by ${label}`;
}
if ( action === 'read' ) {
return `access all protected apps owned by ${label}`;
}
return `access all protected apps owned by ${label}`;
}
if ( action === 'access' ) {
if ( app?.name ) return `access app "${app.name}".`;
if ( app?.uid ) return `access app with UID ${app.uid}.`;
return `access app ${resource_id}.`;
}
if ( action === 'read' ) {
if ( app?.name ) return `access app "${app.name}".`;
if ( app?.uid ) return `access app with UID ${app.uid}.`;
return `access app ${resource_id}.`;
}
if ( action === 'write' ) {
if ( app?.name ) return `modify settings for app "${app.name}".`;
if ( app?.uid ) return `modify settings for app with UID ${app.uid}.`;
return `modify settings for app ${resource_id}.`;
}
return null;
})(),
'site': (() => {
if ( (resource_id ?? '').startsWith('owner#') || (resource_id ?? '').startsWith('owner@') ) {
const label = resource_id.startsWith('owner#')
? resource_id.slice('owner#'.length)
: resource_id.slice('owner@'.length);
if ( action === 'write' ) {
return `modify all protected sites owned by ${label}`;
}
if ( action === 'read' ) {
return `access all protected sites owned by ${label}`;
}
return `access all protected sites owned by ${label}`;
}
if ( action === 'access' || action === 'read' ) {
return `access site ${resource_id}.`;
}
if ( action === 'write' ) {
return `modify settings for site ${resource_id}.`;
}
return null;
})(),
};

return permission_mappings[resource_type];
Expand Down
Loading