Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fine grained access control #108

Draft
wants to merge 42 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
50c1ba5
WIP
karlpauls Dec 8, 2024
d8af262
WIP
karlpauls Dec 10, 2024
a116db7
WIP
karlpauls Dec 10, 2024
975e94e
Make all tests pass
bosschaert Dec 10, 2024
757004b
Small fixes to make things work
bosschaert Dec 12, 2024
98f3ccf
Store permissions on the Ctx
bosschaert Dec 12, 2024
3837ffb
Make unit tests pass again
bosschaert Dec 13, 2024
08a572d
Add test for .html file
bosschaert Dec 13, 2024
f9e052f
Make actions header visible for all requests
bosschaert Dec 16, 2024
2315bfc
Minor fixes
bosschaert Dec 16, 2024
8399fb2
Add /+* syntax and fix copyFile return value
bosschaert Dec 17, 2024
16ec193
Additional tests
bosschaert Dec 17, 2024
424e86d
Update wrangler version
bosschaert Dec 17, 2024
7d4ef72
Permissions on org configuration are specified via the CONFIG path
bosschaert Dec 19, 2024
77e0d42
Make comparisons case-insensitive.
bosschaert Dec 23, 2024
cb49a90
Get all tests to pass
bosschaert Dec 23, 2024
82fcfc4
Remove spaces from the ACL paths, this makes the following path
bosschaert Dec 23, 2024
ad944bc
Test anonymous user
bosschaert Dec 23, 2024
034d222
Make the following the same in ACL path specifications:
bosschaert Dec 23, 2024
9a233a7
Some extra checks and unit tests
bosschaert Dec 24, 2024
b4013c0
Additional logging
bosschaert Dec 24, 2024
bfde12c
Update wrangler version
bosschaert Dec 24, 2024
25ceb9b
Add X-da-acltrace header for admins
bosschaert Jan 3, 2025
cae8672
Remove isAuthorized
bosschaert Jan 3, 2025
b519aef
Unit tests for hasPermission in routes
bosschaert Jan 3, 2025
a60a73a
Small tweak
bosschaert Jan 7, 2025
7dd8a45
Tracing
bosschaert Jan 7, 2025
531c5fd
Return X-da-actions also on non-existing pages
bosschaert Jan 7, 2025
fea391c
Improve handling of ACLTRACE permissions
bosschaert Jan 8, 2025
6865096
Version route tests
bosschaert Jan 8, 2025
e5764d0
Additional version permission test
bosschaert Jan 8, 2025
a85d2a1
Config handling in ACLs
bosschaert Jan 9, 2025
4cf626c
Handle the case where the permissions sheet is as single-sheet
bosschaert Jan 9, 2025
88bb0d2
Remove last dependency on admin.role.all
bosschaert Jan 10, 2025
a7948cc
When saving permissions sheet check for at least one CONFIG writer
bosschaert Jan 10, 2025
bda99ac
Always have read permission on config
bosschaert Jan 10, 2025
e0a73a3
Additional unit tests
bosschaert Jan 15, 2025
06dd824
Allow HTML files to be addressed with the .html suffix.
bosschaert Jan 16, 2025
a3ab7fb
Tests for move
bosschaert Jan 16, 2025
005ad82
Fixes to move
bosschaert Jan 16, 2025
8f8ee4e
Update wrangler
bosschaert Jan 16, 2025
1e060ed
Add logout
bosschaert Jan 16, 2025
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
6,259 changes: 3,486 additions & 2,773 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
"eslint": "8.56.0",
"esmock": "^2.6.4",
"mocha": "^10.2.0",
"wrangler": "^3.57.0"
"wrangler": "^3.103.0"
},
"lint-staged": {
"*.js": "eslint",
Expand Down
2 changes: 2 additions & 0 deletions src/handlers/post.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
import { logout } from '../utils/auth.js';
import { postSource } from '../routes/source.js';
import { postConfig } from '../routes/config.js';
import { postVersionSource } from '../routes/version.js';
Expand All @@ -23,6 +24,7 @@
if (path.startsWith('/versionsource')) return postVersionSource({ req, env, daCtx });
if (path.startsWith('/copy')) return copyHandler({ req, env, daCtx });
if (path.startsWith('/move')) return moveRoute({ req, env, daCtx });
if (path.startsWith('/logout')) return logout({ env, daCtx });

Check warning on line 27 in src/handlers/post.js

View check run for this annotation

Codecov / codecov/patch

src/handlers/post.js#L27

Added line #L27 was not covered by tests

return undefined;
}
2 changes: 1 addition & 1 deletion src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,6 @@ export default {
respObj = unknownHandler();
}

return daResp(respObj);
return daResp(respObj, daCtx);
},
};
15 changes: 13 additions & 2 deletions src/routes/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,22 @@

import putKv from '../storage/kv/put.js';
import getKv from '../storage/kv/get.js';
import { hasPermission } from '../utils/auth.js';

export async function postConfig({ req, env, daCtx }) {
if (!hasPermission(daCtx, 'CONFIG', 'write', true)) {
return { status: 403 };
}

export function postConfig({ req, env, daCtx }) {
return putKv(req, env, daCtx);
}

export function getConfig({ env, daCtx }) {
export async function getConfig({ env, daCtx }) {
// // TODO maybe we should turn the order around?
// if (!hasPermission(daCtx, 'CONFIG', 'read', true)) {
// const key = daCtx.key.startsWith('/') ? daCtx.key : `/${daCtx.key}`
// if (!hasPermission(daCtx, key, 'read', true)) { // TODO
// return { status: 403 };
// }
return getKv(env, daCtx);
}
3 changes: 3 additions & 0 deletions src/routes/copy.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@
*/
import copyObject from '../storage/object/copy.js';
import copyHelper from '../helpers/copy.js';
import { hasPermission } from '../utils/auth.js';

export default async function copyHandler({ req, env, daCtx }) {
const details = await copyHelper(req, daCtx);
if (!hasPermission(daCtx, details.source, 'read')
|| !hasPermission(daCtx, details.destination, 'write')) return { status: 403 };
return copyObject(env, daCtx, details, false);
}
4 changes: 3 additions & 1 deletion src/routes/list.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@
*/
import listBuckets from '../storage/bucket/list.js';
import listObjects from '../storage/object/list.js';
import { hasPermission } from '../utils/auth.js';

export default function getList({ env, daCtx }) {
export default async function getList({ env, daCtx }) {
if (!daCtx.org) return listBuckets(env, daCtx);
if (!hasPermission(daCtx, daCtx.key, 'read')) return { status: 403 };
return listObjects(env, daCtx);
}
3 changes: 3 additions & 0 deletions src/routes/move.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,12 @@
*/
import moveObject from '../storage/object/move.js';
import moveHelper from '../helpers/move.js';
import { hasPermission } from '../utils/auth.js';

export default async function moveRoute({ req, env, daCtx }) {
const details = await moveHelper(req, daCtx);
if (details.error) return details.error;
if (!hasPermission(daCtx, details.source, 'write')
|| !hasPermission(daCtx, details.destination, 'write')) return { status: 403 };
return moveObject(env, daCtx, details);
}
4 changes: 4 additions & 0 deletions src/routes/source.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,16 @@ import { invalidateCollab } from '../storage/utils/object.js';

import putHelper from '../helpers/source.js';
import deleteHelper from '../helpers/delete.js';
import { hasPermission } from '../utils/auth.js';

export async function deleteSource({ req, env, daCtx }) {
if (!hasPermission(daCtx, daCtx.key, 'write')) return { status: 403 };
const details = await deleteHelper(req);
return /* await */ deleteObjects(env, daCtx, details);
}

export async function postSource({ req, env, daCtx }) {
if (!hasPermission(daCtx, daCtx.key, 'write')) return { status: 403 };
const obj = await putHelper(req, env, daCtx);
const resp = await putObject(env, daCtx, obj);

Expand All @@ -36,5 +39,6 @@ export async function postSource({ req, env, daCtx }) {
}

export async function getSource({ env, daCtx, head }) {
if (!hasPermission(daCtx, daCtx.key, 'read')) return { status: 403 };
return getObject(env, daCtx, head);
}
11 changes: 10 additions & 1 deletion src/routes/version.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,24 @@
import { getObjectVersion } from '../storage/version/get.js';
import { listObjectVersions } from '../storage/version/list.js';
import { postObjectVersion } from '../storage/version/put.js';
import { hasPermission } from '../utils/auth.js';

export async function getVersionList({ env, daCtx }) {
if (!hasPermission(daCtx, daCtx.key, 'read')) return { status: 403 };
return listObjectVersions(env, daCtx);
}

export async function getVersionSource({ env, daCtx, head }) {
return getObjectVersion(env, daCtx, head);
// daCtx.key is something like
// 'f85f9b05-ae48-485b-a3b3-dd203ac5c734/1b7e005b-8602-4053-b920-8e67ad8e8dba.html’
// so we have to do the permission check when the object is returned from the metadata.

const resp = await getObjectVersion(env, daCtx, head);
if (!hasPermission(daCtx, resp.metadata?.path, 'read')) return { status: 403 };
return resp;
}

export async function postVersionSource({ req, env, daCtx }) {
if (!hasPermission(daCtx, daCtx.key, 'write')) return { status: 403 };
return postObjectVersion(req, env, daCtx);
}
11 changes: 5 additions & 6 deletions src/storage/bucket/list.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,14 @@
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
import { isAuthorized } from '../../utils/auth.js';
import { hasPermission } from '../../utils/auth.js';

async function isBucketAuthed(env, daCtx, bucket) {
const { name, created } = bucket;
const userAuth = await Promise.all(
daCtx.users.map(async (user) => isAuthorized(env, name, user)),
);
const notAuthed = userAuth.some((authed) => !authed);
if (notAuthed) return null;

if (!hasPermission(daCtx, name, 'read')) {
return null;
}

Check warning on line 19 in src/storage/bucket/list.js

View check run for this annotation

Codecov / codecov/patch

src/storage/bucket/list.js#L16-L19

Added lines #L16 - L19 were not covered by tests
return { name, created };
}

Expand Down
21 changes: 20 additions & 1 deletion src/storage/kv/put.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,31 @@
* governing permissions and limitations under the License.
*/

function checkConfigWriter(json) {
// Handle both single and multi-sheet
const data = json[':sheetname'] === 'permissions' && json[':type'] === 'sheet'
? json.data
: json?.permissions?.data;

if (!data) return true; // Not a permission sheet

return data.some((e) => e.path?.trim() === 'CONFIG' && e.actions?.trim() === 'write' && e.groups?.trim().length > 0);
}

async function save(env, key, string) {
let body;
let status;
try {
// Parse it to at least validate its json
JSON.parse(string);
const json = JSON.parse(string);

if (!checkConfigWriter(json)) {
return {
body: JSON.stringify({ error: 'Should at least specify one user or group that has CONFIG write permission' }),
status: 400,
};
}

// Put it (seems to not return a response)
await env.DA_CONFIG.put(key, string);
// Validate the content is there
Expand Down
9 changes: 9 additions & 0 deletions src/storage/object/copy.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,21 @@ import getS3Config from '../utils/config.js';
import { invalidateCollab } from '../utils/object.js';
import { putObjectWithVersion } from '../version/put.js';
import { listCommand } from '../utils/list.js';
import { hasPermission } from '../../utils/auth.js';

const MAX_KEYS = 900;

export const copyFile = async (config, env, daCtx, sourceKey, details, isRename) => {
const Key = `${sourceKey.replace(details.source, details.destination)}`;

if (!hasPermission(daCtx, sourceKey, 'read') || !hasPermission(daCtx, Key, 'write')) {
return {
$metadata: {
httpStatusCode: 403,
},
};
}

const input = {
Bucket: `${daCtx.org}-content`,
Key,
Expand Down
7 changes: 4 additions & 3 deletions src/storage/object/delete.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import getS3Config from '../utils/config.js';
import { invalidateCollab } from '../utils/object.js';
// import { postObjectVersionWithLabel } from '../version/put.js';
import { listCommand } from '../utils/list.js';
import { hasPermission } from '../../utils/auth.js';

export async function deleteObject(client, daCtx, Key, env /* , isMove = false */) {
// const fname = Key.split('/').pop();
Expand Down Expand Up @@ -52,9 +53,9 @@ export default async function deleteObjects(env, daCtx, details) {

try {
const { sourceKeys, continuationToken } = await listCommand(daCtx, details, client);
await Promise.all(sourceKeys.map(async (key) => {
await deleteObject(client, daCtx, key, env);
}));

const deleteKeys = sourceKeys.filter((key) => hasPermission(daCtx, key, 'write'));
await Promise.all(deleteKeys.map(async (key) => deleteObject(client, daCtx, key, env)));

if (continuationToken) {
return { body: JSON.stringify({ continuationToken }), status: 206 };
Expand Down
28 changes: 16 additions & 12 deletions src/storage/object/move.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
import getS3Config from '../utils/config.js';
import { deleteObject } from './delete.js';
import { copyFile } from './copy.js';
import { hasPermission } from '../../utils/auth.js';

function buildInput(org, key) {
return {
Expand Down Expand Up @@ -49,18 +50,21 @@ export default async function moveObject(env, daCtx, details) {
const { Contents = [], NextContinuationToken } = resp;
sourceKeys.push(...Contents.map(({ Key }) => Key));

const movedLoad = sourceKeys.map(async (key) => {
const result = { key };
const copied = await copyFile(config, env, daCtx, key, details, true);
// Only delete the source if the file was successfully copied
if (copied.$metadata.httpStatusCode === 200) {
const deleted = await deleteObject(client, daCtx, key, env, true);
result.status = deleted.status === 204 ? 204 : deleted.status;
} else {
result.status = copied.$metadata.httpStatusCode;
}
return result;
});
const movedLoad = sourceKeys
.filter((key) => hasPermission(daCtx, key, 'write'))
.filter((key) => hasPermission(daCtx, key.replace(details.source, details.destination), 'write'))
.map(async (key) => {
const result = { key };
const copied = await copyFile(config, env, daCtx, key, details, true);
// Only delete the source if the file was successfully copied
if (copied.$metadata.httpStatusCode === 200) {
const deleted = await deleteObject(client, daCtx, key, env, true);
result.status = deleted.status === 204 ? 204 : deleted.status;
} else {
result.status = copied.$metadata.httpStatusCode;
}
return result;
});

results.push(...await Promise.all(movedLoad));

Expand Down
Loading
Loading