feat(acl): granular per-folder permissions + stricter gates; WebDAV & UI aligned

This commit is contained in:
Ryan
2025-10-22 21:36:04 -04:00
committed by GitHub
parent 0ad7034a7d
commit ee717af750
15 changed files with 1332 additions and 825 deletions

View File

@@ -1,28 +1,5 @@
<?php
// public/api/admin/acl/getGrants.php
/**
* @OA\Get(
* path="/api/admin/acl/getGrants.php",
* summary="Get ACL grants for a user",
* tags={"Admin","ACL"},
* security={{"cookieAuth":{}}},
* @OA\Parameter(name="user", in="query", required=true, @OA\Schema(type="string")),
* @OA\Response(
* response=200,
* description="Map of folder → grant flags",
* @OA\JsonContent(
* type="object",
* required={"grants"},
* @OA\Property(property="grants", ref="#/components/schemas/GrantsMap")
* )
* ),
* @OA\Response(response=400, description="Invalid user"),
* @OA\Response(response=401, description="Unauthorized")
* )
*/
declare(strict_types=1);
require_once __DIR__ . '/../../../../config/config.php';
@@ -32,7 +9,6 @@ require_once PROJECT_ROOT . '/src/models/FolderModel.php';
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
header('Content-Type: application/json');
// Admin only
if (empty($_SESSION['authenticated']) || empty($_SESSION['isAdmin'])) {
http_response_code(401); echo json_encode(['error'=>'Unauthorized']); exit;
}
@@ -55,7 +31,7 @@ try {
} catch (Throwable $e) { /* ignore */ }
if (empty($folders)) {
$aclPath = META_DIR . 'folder_acl.json';
$aclPath = rtrim(META_DIR, "/\\") . DIRECTORY_SEPARATOR . 'folder_acl.json';
if (is_file($aclPath)) {
$data = json_decode((string)@file_get_contents($aclPath), true);
if (is_array($data['folders'] ?? null)) {
@@ -74,29 +50,36 @@ $has = function(array $arr, string $u): bool {
$out = [];
foreach ($folderList as $f) {
$rec = ACL::explicit($f); // owners, read, write, share, read_own
$rec = ACL::explicitAll($f); // legacy + granular
$isOwner = $has($rec['owners'], $user);
$canUpload = $isOwner || $has($rec['write'], $user);
// IMPORTANT: full view only if owner or explicit read
$isOwner = $has($rec['owners'], $user);
$canViewAll = $isOwner || $has($rec['read'], $user);
// own-only view reflects explicit read_own (we keep it separate even if they have full view)
$canViewOwn = $has($rec['read_own'], $user);
$canShare = $isOwner || $has($rec['share'], $user);
$canUpload = $isOwner || $has($rec['write'], $user) || $has($rec['upload'], $user);
// Share only if owner or explicit share
$canShare = $isOwner || $has($rec['share'], $user);
if ($canViewAll || $canViewOwn || $canUpload || $isOwner || $canShare) {
if ($canViewAll || $canViewOwn || $canUpload || $canShare || $isOwner
|| $has($rec['create'],$user) || $has($rec['edit'],$user) || $has($rec['rename'],$user)
|| $has($rec['copy'],$user) || $has($rec['move'],$user) || $has($rec['delete'],$user)
|| $has($rec['extract'],$user) || $has($rec['share_file'],$user) || $has($rec['share_folder'],$user)) {
$out[$f] = [
'view' => $canViewAll,
'viewOwn' => $canViewOwn,
'upload' => $canUpload,
'manage' => $isOwner,
'share' => $canShare,
'view' => $canViewAll,
'viewOwn' => $canViewOwn,
'write' => $has($rec['write'], $user) || $isOwner,
'manage' => $isOwner,
'share' => $canShare, // legacy
'create' => $isOwner || $has($rec['create'], $user),
'upload' => $isOwner || $has($rec['upload'], $user) || $has($rec['write'],$user),
'edit' => $isOwner || $has($rec['edit'], $user) || $has($rec['write'],$user),
'rename' => $isOwner || $has($rec['rename'], $user) || $has($rec['write'],$user),
'copy' => $isOwner || $has($rec['copy'], $user) || $has($rec['write'],$user),
'move' => $isOwner || $has($rec['move'], $user) || $has($rec['write'],$user),
'delete' => $isOwner || $has($rec['delete'], $user) || $has($rec['write'],$user),
'extract' => $isOwner || $has($rec['extract'], $user)|| $has($rec['write'],$user),
'shareFile' => $isOwner || $has($rec['share_file'], $user) || $has($rec['share'],$user),
'shareFolder' => $isOwner || $has($rec['share_folder'], $user) || $has($rec['share'],$user),
];
}
}
echo json_encode(['grants' => $out], JSON_UNESCAPED_SLASHES);
echo json_encode(['grants' => $out], JSON_UNESCAPED_SLASHES);

View File

@@ -1,27 +1,5 @@
<?php
// public/api/admin/acl/saveGrants.php
/**
* @OA\Post(
* path="/api/admin/acl/saveGrants.php",
* summary="Save ACL grants (single-user or batch)",
* tags={"Admin","ACL"},
* security={{"cookieAuth":{}}},
* @OA\RequestBody(
* required=true,
* description="Either {user,grants} or {changes:[{user,grants}]}",
* @OA\JsonContent(oneOf={
* @OA\Schema(ref="#/components/schemas/SaveGrantsSingle"),
* @OA\Schema(ref="#/components/schemas/SaveGrantsBatch")
* })
* ),
* @OA\Response(response=200, description="Saved"),
* @OA\Response(response=400, description="Invalid payload"),
* @OA\Response(response=401, description="Unauthorized"),
* @OA\Response(response=403, description="Invalid CSRF")
* )
*/
declare(strict_types=1);
require_once __DIR__ . '/../../../../config/config.php';
@@ -47,22 +25,38 @@ if (empty($_SESSION['csrf_token']) || $csrf !== $_SESSION['csrf_token']) {
}
// ---- Helpers ---------------------------------------------------------------
/**
* Sanitize a grants map to allowed flags only:
* view | viewOwn | upload | manage | share
*/
function normalize_caps(array $row): array {
// booleanize known keys
$bool = function($v){ return !empty($v) && $v !== 'false' && $v !== 0; };
$k = [
'view','viewOwn','upload','manage','share',
'create','edit','rename','copy','move','delete','extract',
'shareFile','shareFolder','write'
];
$out = [];
foreach ($k as $kk) $out[$kk] = $bool($row[$kk] ?? false);
// BUSINESS RULES:
// A) Share Folder REQUIRES View (all). If shareFolder is true but view is false, force view=true.
if ($out['shareFolder'] && !$out['view']) {
$out['view'] = true;
}
// B) Share File requires at least View (own). If neither view nor viewOwn set, set viewOwn=true.
if ($out['shareFile'] && !$out['view'] && !$out['viewOwn']) {
$out['viewOwn'] = true;
}
// C) "write" does NOT imply view. It also does not imply granular here; ACL expands legacy write if present.
return $out;
}
function sanitize_grants_map(array $grants): array {
$allowed = ['view','viewOwn','upload','manage','share'];
$out = [];
foreach ($grants as $folder => $caps) {
if (!is_string($folder)) $folder = (string)$folder;
if (!is_array($caps)) $caps = [];
$row = [];
foreach ($allowed as $k) {
$row[$k] = !empty($caps[$k]);
}
// include folder even if all false (signals "remove all for this user on this folder")
$out[$folder] = $row;
$out[$folder] = normalize_caps($caps);
}
return $out;
}
@@ -124,4 +118,4 @@ if (isset($in['changes']) && is_array($in['changes'])) {
// ---- Fallback --------------------------------------------------------------
http_response_code(400);
echo json_encode(['error' => 'Invalid payload: expected {user,grants} or {changes:[{user,grants}]}']);
echo json_encode(['error' => 'Invalid payload: expected {user,grants} or {changes:[{user,grants}]}']);

View File

@@ -51,7 +51,7 @@
* )
*/
declare(strict_types=1);
if (session_status() !== PHP_SESSION_ACTIVE) session_start();
require_once __DIR__ . '/../../../config/config.php';
@@ -88,30 +88,50 @@ function loadPermsFor(string $u): array {
return [];
}
function isAdminUser(string $u, array $perms): bool {
if (!empty($perms['admin']) || !empty($perms['isAdmin'])) return true;
if (!empty($_SESSION['isAdmin']) && $_SESSION['isAdmin'] === true) return true;
$role = $_SESSION['role'] ?? null;
if ($role === 'admin' || $role === '1' || $role === 1) return true;
if ($u) {
$r = userModel::getUserRole($u);
if ($r === '1') return true;
function isOwnerOrAncestorOwner(string $user, array $perms, string $folder): bool {
$f = ACL::normalizeFolder($folder);
// direct owner
if (ACL::isOwner($user, $perms, $f)) return true;
// ancestor owner
while ($f !== '' && strcasecmp($f, 'root') !== 0) {
$pos = strrpos($f, '/');
if ($pos === false) break;
$f = substr($f, 0, $pos);
if ($f === '' || strcasecmp($f, 'root') === 0) break;
if (ACL::isOwner($user, $perms, $f)) return true;
}
return false;
}
/**
* folder-only scope:
* - Admins: always in scope
* - Non folder-only accounts: always in scope
* - Folder-only accounts: in scope iff:
* - folder == username OR subpath of username, OR
* - user is owner of this folder (or any ancestor)
*/
function inUserFolderScope(string $folder, string $u, array $perms, bool $isAdmin): bool {
if ($isAdmin) return true;
$folderOnly = !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']);
if (!$folderOnly) return true;
$f = trim($folder);
if ($f === '' || strcasecmp($f, 'root') === 0) return false; // non-admin folderOnly: not root
return ($f === $u) || (strpos($f, $u . '/') === 0);
//$folderOnly = !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']);
//if (!$folderOnly) return true;
$f = ACL::normalizeFolder($folder);
if ($f === 'root' || $f === '') {
// folder-only users cannot act on root unless they own a subfolder (handled below)
return isOwnerOrAncestorOwner($u, $perms, $f);
}
if ($f === $u || str_starts_with($f, $u . '/')) return true;
// Treat ownership as in-scope
return isOwnerOrAncestorOwner($u, $perms, $f);
}
// --- inputs ---
$folder = isset($_GET['folder']) ? trim((string)$_GET['folder']) : 'root';
// validate folder path: allow "root" or nested segments matching REGEX_FOLDER_NAME
// validate folder path
if ($folder !== 'root') {
$parts = array_filter(explode('/', trim($folder, "/\\ ")));
if (empty($parts)) {
@@ -129,44 +149,90 @@ if ($folder !== 'root') {
$folder = implode('/', $parts);
}
$perms = loadPermsFor($username);
$isAdmin = isAdminUser($username, $perms);
// --- user + flags ---
$perms = loadPermsFor($username);
$isAdmin = ACL::isAdmin($perms);
$readOnly = !empty($perms['readOnly']);
$disableUp = !empty($perms['disableUpload']);
$inScope = inUserFolderScope($folder, $username, $perms, $isAdmin);
// base permissions via ACL
$canRead = $isAdmin || ACL::canRead($username, $perms, $folder);
$canWrite = $isAdmin || ACL::canWrite($username, $perms, $folder);
$canShare = $isAdmin || ACL::canShare($username, $perms, $folder);
// --- ACL base abilities ---
$canViewBase = $isAdmin || ACL::canRead($username, $perms, $folder);
$canViewOwn = $isAdmin || ACL::canReadOwn($username, $perms, $folder);
$canWriteBase = $isAdmin || ACL::canWrite($username, $perms, $folder);
$canShareBase = $isAdmin || ACL::canShare($username, $perms, $folder);
// scope + flags
$inScope = inUserFolderScope($folder, $username, $perms, $isAdmin);
$readOnly = !empty($perms['readOnly']);
$disableUpload = !empty($perms['disableUpload']);
$canManageBase = $isAdmin || ACL::canManage($username, $perms, $folder);
$canUpload = $canWrite && !$readOnly && !$disableUpload && $inScope;
$canCreateFolder = $canWrite && !$readOnly && $inScope;
$canRename = $canWrite && !$readOnly && $inScope;
$canDelete = $canWrite && !$readOnly && $inScope;
$canMoveIn = $canWrite && !$readOnly && $inScope;
// granular base
$gCreateBase = $isAdmin || ACL::canCreate($username, $perms, $folder);
$gRenameBase = $isAdmin || ACL::canRename($username, $perms, $folder);
$gDeleteBase = $isAdmin || ACL::canDelete($username, $perms, $folder);
$gMoveBase = $isAdmin || ACL::canMove($username, $perms, $folder);
$gUploadBase = $isAdmin || ACL::canUpload($username, $perms, $folder);
$gEditBase = $isAdmin || ACL::canEdit($username, $perms, $folder);
$gCopyBase = $isAdmin || ACL::canCopy($username, $perms, $folder);
$gExtractBase = $isAdmin || ACL::canExtract($username, $perms, $folder);
$gShareFile = $isAdmin || ACL::canShareFile($username, $perms, $folder);
$gShareFolder = $isAdmin || ACL::canShareFolder($username, $perms, $folder);
// (optional) owner info if you need it client-side
$owner = FolderModel::getOwnerFor($folder);
// --- Apply scope + flags to effective UI actions ---
$canView = $canViewBase && $inScope; // keep scope for folder-only
$canUpload = $gUploadBase && !$readOnly && !$disableUpload && $inScope;
$canCreate = $canManageBase && !$readOnly && $inScope; // Create **folder**
$canRename = $canManageBase && !$readOnly && $inScope; // Rename **folder**
$canDelete = $gDeleteBase && !$readOnly && $inScope;
// Destination can receive items if user can create/write (or manage) here
$canReceive = ($gUploadBase || $gCreateBase || $canManageBase) && !$readOnly && $inScope;
// Back-compat: expose as canMoveIn (used by toolbar/context-menu/drag&drop)
$canMoveIn = $canReceive;
$canEdit = $gEditBase && !$readOnly && $inScope;
$canCopy = $gCopyBase && !$readOnly && $inScope;
$canExtract = $gExtractBase && !$readOnly && $inScope;
// Sharing respects scope; optionally also gate on readOnly
$canShare = $canShareBase && $inScope; // legacy umbrella
$canShareFileEff = $gShareFile && $inScope;
$canShareFoldEff = $gShareFolder && $inScope;
// never allow destructive ops on root
$isRoot = ($folder === 'root');
if ($isRoot) {
$canRename = false;
$canDelete = false;
$canShareFoldEff = false;
}
$owner = null;
try { $owner = FolderModel::getOwnerFor($folder); } catch (Throwable $e) {}
// output
echo json_encode([
'user' => $username,
'folder' => $folder,
'isAdmin' => $isAdmin,
'flags' => [
'folderOnly' => !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']),
'user' => $username,
'folder' => $folder,
'isAdmin' => $isAdmin,
'flags' => [
//'folderOnly' => !empty($perms['folderOnly']) || !empty($perms['userFolderOnly']) || !empty($perms['UserFolderOnly']),
'readOnly' => $readOnly,
'disableUpload' => $disableUpload,
'disableUpload' => $disableUp,
],
'owner' => $owner,
'canView' => $canRead,
'canUpload' => $canUpload,
'canCreate' => $canCreateFolder,
'canRename' => $canRename,
'canDelete' => $canDelete,
'canMoveIn' => $canMoveIn,
'canShare' => $canShare,
'owner' => $owner,
// viewing
'canView' => $canView,
'canViewOwn' => $canViewOwn,
// write-ish
'canUpload' => $canUpload,
'canCreate' => $canCreate,
'canRename' => $canRename,
'canDelete' => $canDelete,
'canMoveIn' => $canMoveIn,
'canEdit' => $canEdit,
'canCopy' => $canCopy,
'canExtract' => $canExtract,
// sharing
'canShare' => $canShare, // legacy
'canShareFile' => $canShareFileEff,
'canShareFolder' => $canShareFoldEff,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);

File diff suppressed because it is too large Load Diff

View File

@@ -86,26 +86,26 @@ export function getParentFolder(folder) {
Breadcrumb Functions
----------------------*/
function setControlEnabled(el, enabled) {
if (!el) return;
if ('disabled' in el) el.disabled = !enabled;
el.classList.toggle('disabled', !enabled);
el.setAttribute('aria-disabled', String(!enabled));
el.style.pointerEvents = enabled ? '' : 'none';
el.style.opacity = enabled ? '' : '0.5';
}
async function applyFolderCapabilities(folder) {
try {
const res = await fetch(`/api/folder/capabilities.php?folder=${encodeURIComponent(folder)}`, { credentials: 'include' });
if (!res.ok) return;
const caps = await res.json();
const res = await fetch(`/api/folder/capabilities.php?folder=${encodeURIComponent(folder)}`, { credentials: 'include' });
if (!res.ok) return;
const caps = await res.json();
window.currentFolderCaps = caps;
// top buttons
const createBtn = document.getElementById('createFolderBtn');
const renameBtn = document.getElementById('renameFolderBtn');
const deleteBtn = document.getElementById('deleteFolderBtn');
const shareBtn = document.getElementById('shareFolderBtn');
if (createBtn) createBtn.disabled = !caps.canCreate;
if (renameBtn) renameBtn.disabled = !caps.canRename || folder === 'root';
if (deleteBtn) deleteBtn.disabled = !caps.canDelete || folder === 'root';
if (shareBtn) shareBtn.disabled = !caps.canShare || folder === 'root';
// keep for later if you want context menu to reflect caps
window.currentFolderCaps = caps;
} catch {}
const isRoot = (folder === 'root');
setControlEnabled(document.getElementById('createFolderBtn'), !!caps.canCreate);
setControlEnabled(document.getElementById('renameFolderBtn'), !isRoot && !!caps.canRename);
setControlEnabled(document.getElementById('deleteFolderBtn'), !isRoot && !!caps.canDelete);
setControlEnabled(document.getElementById('shareFolderBtn'), !isRoot && !!caps.canShareFolder);
}
// --- Breadcrumb Delegation Setup ---
@@ -146,6 +146,7 @@ function breadcrumbClickHandler(e) {
document.querySelectorAll(".folder-option").forEach(el => el.classList.remove("selected"));
const target = document.querySelector(`.folder-option[data-folder="${folder}"]`);
if (target) target.classList.add("selected");
applyFolderCapabilities(window.currentFolder);
loadFileList(folder);
}
@@ -824,6 +825,7 @@ function folderManagerContextMenuHandler(e) {
const folder = target.getAttribute("data-folder");
if (!folder) return;
window.currentFolder = folder;
applyFolderCapabilities(window.currentFolder);
// Visual selection
document.querySelectorAll(".folder-option, .breadcrumb-link").forEach(el => el.classList.remove("selected"));