permission update, routes, etc.

This commit is contained in:
Roman 2024-03-27 20:50:57 +01:00
parent a8f4c84f60
commit 50ae32595d
15 changed files with 131 additions and 69 deletions

@ -34,6 +34,7 @@ namespace Core\API\Permission {
use Core\API\Parameter\StringType; use Core\API\Parameter\StringType;
use Core\API\PermissionAPI; use Core\API\PermissionAPI;
use Core\Driver\SQL\Column\Column; use Core\Driver\SQL\Column\Column;
use Core\Driver\SQL\Condition\CondIn;
use Core\Driver\SQL\Condition\CondLike; use Core\Driver\SQL\Condition\CondLike;
use Core\Driver\SQL\Query\Insert; use Core\Driver\SQL\Query\Insert;
use Core\Driver\SQL\Strategy\UpdateStrategy; use Core\Driver\SQL\Strategy\UpdateStrategy;
@ -168,25 +169,25 @@ namespace Core\API\Permission {
return $this->createError("This method cannot be updated."); return $this->createError("This method cannot be updated.");
} }
$groups = $this->getParam("groups"); $groupIds = array_unique($this->getParam("groups"));
if (!empty($groups)) { if (!empty($groupIds)) {
sort($groups); sort($groupIds);
$availableGroups = Group::findAll($sql); $availableGroups = Group::findAll($sql, new CondIn(new Column("id"), $groupIds));
foreach ($groups as $groupId) { foreach ($groupIds as $groupId) {
if (!isset($availableGroups[$groupId])) { if (!isset($availableGroups[$groupId])) {
return $this->createError("Unknown group id: $groupId"); return $this->createError("Group with id=$groupId does not exist.");
} }
} }
} }
if ($description === null) { if ($description === null) {
$updateQuery = $sql->insert("ApiPermission", ["method", "groups", "isCore"]) $updateQuery = $sql->insert("ApiPermission", ["method", "groups", "isCore"])
->onDuplicateKeyStrategy(new UpdateStrategy(["method"], ["groups" => $groups])) ->onDuplicateKeyStrategy(new UpdateStrategy(["method"], ["groups" => $groupIds]))
->addRow($method, $groups, false); ->addRow($method, $groupIds, false);
} else { } else {
$updateQuery = $sql->insert("ApiPermission", ["method", "groups", "isCore", "description"]) $updateQuery = $sql->insert("ApiPermission", ["method", "groups", "isCore", "description"])
->onDuplicateKeyStrategy(new UpdateStrategy(["method"], ["groups" => $groups, "description" => $description])) ->onDuplicateKeyStrategy(new UpdateStrategy(["method"], ["groups" => $groupIds, "description" => $description]))
->addRow($method, $groups, false, $description); ->addRow($method, $groupIds, false, $description);
} }
$this->success = $updateQuery->execute() !== false; $this->success = $updateQuery->execute() !== false;

@ -67,19 +67,11 @@ namespace Core\API\Routes {
use Core\API\Parameter\Parameter; use Core\API\Parameter\Parameter;
use Core\API\Parameter\StringType; use Core\API\Parameter\StringType;
use Core\API\RoutesAPI; use Core\API\RoutesAPI;
use Core\Driver\SQL\Condition\Compare;
use Core\Driver\SQL\Condition\CondBool;
use Core\Driver\SQL\Query\Insert; use Core\Driver\SQL\Query\Insert;
use Core\Driver\SQL\Query\StartTransaction;
use Core\Objects\Context; use Core\Objects\Context;
use Core\Objects\DatabaseEntity\Group; use Core\Objects\DatabaseEntity\Group;
use Core\Objects\DatabaseEntity\Route; use Core\Objects\DatabaseEntity\Route;
use Core\Objects\Router\DocumentRoute;
use Core\Objects\Router\RedirectPermanentlyRoute;
use Core\Objects\Router\RedirectRoute;
use Core\Objects\Router\RedirectTemporaryRoute;
use Core\Objects\Router\Router; use Core\Objects\Router\Router;
use Core\Objects\Router\StaticFileRoute;
class Fetch extends RoutesAPI { class Fetch extends RoutesAPI {
@ -419,5 +411,39 @@ namespace Core\API\Routes {
$insert->addRow(self::getEndpoint(), [Group::ADMIN, Group::SUPPORT], "Allows users to regenerate the routing cache", true); $insert->addRow(self::getEndpoint(), [Group::ADMIN, Group::SUPPORT], "Allows users to regenerate the routing cache", true);
} }
} }
class Check extends RoutesAPI {
public function __construct(Context $context, bool $externalCall) {
parent::__construct($context, $externalCall, [
"id" => new Parameter("id", Parameter::TYPE_INT),
"path" => new StringType("path")
]);
}
protected function _execute(): bool {
$sql = $this->context->getSQL();
$routeId = $this->getParam("id");
$route = Route::find($sql, $routeId);
if ($route === false) {
$this->lastError = $sql->getLastError();
return false;
} else if ($route === null) {
return $this->createError("Route not found");
}
$this->success = true;
$path = $this->getParam("path");
$this->result["match"] = $route->match($path);
return $this->success;
}
public static function getDefaultACL(Insert $insert): void {
$insert->addRow("routes/check",
[Group::ADMIN, Group::MODERATOR],
"Users with this permission can see, if a route is matched with the given path for debugging purposes",
true
);
}
}
} }

@ -183,14 +183,14 @@ namespace Core\API\User {
$groups = []; $groups = [];
$sql = $this->context->getSQL(); $sql = $this->context->getSQL();
// TODO: Currently low-privileged users can request any groups here, so a simple privilege escalation is possible. \
// what do? limit access to user/create to admins only?
$requestedGroups = array_unique($this->getParam("groups")); $requestedGroups = array_unique($this->getParam("groups"));
if (!empty($requestedGroups)) { if (!empty($requestedGroups)) {
$groups = Group::findAll($sql, new CondIn(new Column("id"), $requestedGroups)); $availableGroups = Group::findAll($sql, new CondIn(new Column("id"), $requestedGroups));
foreach ($requestedGroups as $groupId) { foreach ($requestedGroups as $groupId) {
if (!isset($groups[$groupId])) { if (!isset($availableGroups[$groupId])) {
return $this->createError("Group with id=$groupId does not exist."); return $this->createError("Group with id=$groupId does not exist.");
} else if ($groupId === Group::ADMIN && !$this->context->getUser()->hasGroup(Group::ADMIN)) {
return $this->createError("You cannot create users with administrator groups.");
} }
} }
} }
@ -632,7 +632,7 @@ namespace Core\API\User {
public function __construct(Context $context, bool $externalCall = false) { public function __construct(Context $context, bool $externalCall = false) {
$parameters = array( $parameters = array(
"username" => new StringType("username", 32), "username" => new StringType("username", 32),
'email' => new Parameter('email', Parameter::TYPE_EMAIL), "email" => new Parameter("email", Parameter::TYPE_EMAIL),
"password" => new StringType("password"), "password" => new StringType("password"),
"confirmPassword" => new StringType("confirmPassword"), "confirmPassword" => new StringType("confirmPassword"),
); );
@ -746,7 +746,7 @@ namespace Core\API\User {
'fullName' => new StringType('fullName', 64, true, NULL), 'fullName' => new StringType('fullName', 64, true, NULL),
'email' => new Parameter('email', Parameter::TYPE_EMAIL, true, NULL), 'email' => new Parameter('email', Parameter::TYPE_EMAIL, true, NULL),
'password' => new StringType('password', -1, true, NULL), 'password' => new StringType('password', -1, true, NULL),
'groups' => new Parameter('groups', Parameter::TYPE_ARRAY, true, NULL), 'groups' => new ArrayType('groups', Parameter::TYPE_INT, true, true, NULL),
'confirmed' => new Parameter('confirmed', Parameter::TYPE_BOOLEAN, true, NULL) 'confirmed' => new Parameter('confirmed', Parameter::TYPE_BOOLEAN, true, NULL)
)); ));
@ -777,19 +777,18 @@ namespace Core\API\User {
$groupIds = array(); $groupIds = array();
if (!is_null($groups)) { if (!is_null($groups)) {
$param = new Parameter('groupId', Parameter::TYPE_INT); $groupIds = array_unique($groups);
foreach ($groups as $groupId) {
if (!$param->parseParam($groupId)) {
$value = print_r($groupId, true);
return $this->createError("Invalid Type for groupId in parameter groups: '$value' (Required: " . $param->getTypeName() . ")");
}
$groupIds[] = $param->value;
}
if ($id === $currentUser->getId() && !in_array(Group::ADMIN, $groupIds)) { if ($id === $currentUser->getId() && !in_array(Group::ADMIN, $groupIds)) {
return $this->createError("Cannot remove Administrator group from own user."); return $this->createError("Cannot remove Administrator group from own user.");
} else if (in_array(Group::ADMIN, $groupIds) && !$currentUser->hasGroup(Group::ADMIN)) {
return $this->createError("You cannot add the administrator group to other users.");
}
$availableGroups = Group::findAll($sql, new CondIn(new Column("id"), $groupIds));
foreach ($groupIds as $groupId) {
if (!isset($availableGroups[$groupId])) {
return $this->createError("Group with id=$groupId does not exist.");
}
} }
} }

@ -8,6 +8,7 @@ use Core\Elements\Document;
use Core\Objects\DatabaseEntity\GpgKey; use Core\Objects\DatabaseEntity\GpgKey;
use Core\Objects\DatabaseEntity\Language; use Core\Objects\DatabaseEntity\Language;
use Core\Objects\Router\Router; use Core\Objects\Router\Router;
use DateTimeInterface;
// Source: https://www.rfc-editor.org/rfc/rfc9116 // Source: https://www.rfc-editor.org/rfc/rfc9116
class Security extends Document { class Security extends Document {
@ -42,12 +43,12 @@ class Security extends Document {
$lines = [ $lines = [
"# This project is based on the open-source framework hosted on https://github.com/rhergenreder/web-base", "# This project is based on the open-source framework hosted on https://github.com/rhergenreder/web-base",
"# Any non-site specific issues can be reported via the github security reporting feature:", "# Any non site-specific issues can be reported via the github security reporting feature:",
"# https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing/privately-reporting-a-security-vulnerability", "# https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing/privately-reporting-a-security-vulnerability",
"", "",
"Canonical: $baseUrl/.well-known/security.txt", "Canonical: $baseUrl/.well-known/security.txt",
"Preferred-Languages: $languageCodes", "Preferred-Languages: $languageCodes",
"Expires: " . $expires->format(\DateTime::ATOM), "Expires: " . $expires->format(DateTimeInterface::ATOM),
"", "",
]; ];
@ -85,6 +86,9 @@ class Security extends Document {
return "Error exporting public key: " . $res["msg"]; return "Error exporting public key: " . $res["msg"];
} }
} }
} else {
http_response_code(412);
return "No gpg key configured yet.";
} }
} }

@ -5,8 +5,11 @@ return [
"dashboard" => "Dashboard", "dashboard" => "Dashboard",
"visitor_statistics" => "Besucherstatistiken", "visitor_statistics" => "Besucherstatistiken",
"user_groups" => "Benutzer & Gruppen", "user_groups" => "Benutzer & Gruppen",
"users" => "Benutzer",
"groups" => "Gruppen",
"page_routes" => "Seiten & Routen", "page_routes" => "Seiten & Routen",
"settings" => "Einstellungen", "settings" => "Einstellungen",
"acl" => "Zugriffsberechtigung",
"logs" => "Logs", "logs" => "Logs",
"help" => "Hilfe", "help" => "Hilfe",
]; ];

@ -4,9 +4,12 @@ return [
"title" => "Administration", "title" => "Administration",
"dashboard" => "Dashboard", "dashboard" => "Dashboard",
"visitor_statistics" => "Visitor Statistics", "visitor_statistics" => "Visitor Statistics",
"user_groups" => "User & Groups", "user_groups" => "Users & Groups",
"users" => "Users",
"groups" => "Groups",
"page_routes" => "Pages & Routes", "page_routes" => "Pages & Routes",
"settings" => "Settings", "settings" => "Settings",
"acl" => "Access Control",
"logs" => "Logs", "logs" => "Logs",
"help" => "Help", "help" => "Help",
]; ];

@ -56,6 +56,10 @@ abstract class Route extends DatabaseEntity {
$this->active = true; $this->active = true;
} }
public function isActive(): bool {
return $this->active;
}
private static function parseParamType(?string $type): ?int { private static function parseParamType(?string $type): ?int {
if ($type === null || trim($type) === "") { if ($type === null || trim($type) === "") {
return null; return null;
@ -115,7 +119,7 @@ abstract class Route extends DatabaseEntity {
} }
} }
public function match(string $url) { public function match(string $url): bool|array {
# /test/{abc}/{param:?}/{xyz:int}/{aaa:int?} # /test/{abc}/{param:?}/{xyz:int}/{aaa:int?}
$patternParts = self::getParts(Router::cleanURL($this->pattern, false)); $patternParts = self::getParts(Router::cleanURL($this->pattern, false));

@ -61,7 +61,7 @@ class DocumentRoute extends Route {
return true; return true;
} }
public function match(string $url) { public function match(string $url): bool|array {
$match = parent::match($url); $match = parent::match($url);
if ($match === false || !$this->loadClass()) { if ($match === false || !$this->loadClass()) {
return false; return false;

@ -16,8 +16,8 @@ import clsx from "clsx";
const Overview = lazy(() => import('./views/overview')); const Overview = lazy(() => import('./views/overview'));
const UserListView = lazy(() => import('./views/user/user-list')); const UserListView = lazy(() => import('./views/user/user-list'));
const UserEditView = lazy(() => import('./views/user/user-edit')); const UserEditView = lazy(() => import('./views/user/user-edit'));
const GroupListView = lazy(() => import('./views/group-list')); const GroupListView = lazy(() => import('./views/group/group-list'));
const EditGroupView = lazy(() => import('./views/group-edit')); const EditGroupView = lazy(() => import('./views/group/group-edit'));
const LogView = lazy(() => import("./views/log-view")); const LogView = lazy(() => import("./views/log-view"));
const AccessControlList = lazy(() => import("./views/access-control-list")); const AccessControlList = lazy(() => import("./views/access-control-list"));

@ -29,9 +29,13 @@ export default function Sidebar(props) {
"icon": "chart-bar", "icon": "chart-bar",
}, },
"users": { "users": {
"name": "admin.user_groups", "name": "admin.users",
"icon": "users" "icon": "users"
}, },
"groups": {
"name": "admin.groups",
"icon": "users-cog"
},
"pages": { "pages": {
"name": "admin.page_routes", "name": "admin.page_routes",
"icon": "copy", "icon": "copy",

@ -5,30 +5,40 @@ import {DataColumn, DataTable, NumericColumn, StringColumn} from "shared/element
import {Button, IconButton} from "@material-ui/core"; import {Button, IconButton} from "@material-ui/core";
import EditIcon from '@mui/icons-material/Edit'; import EditIcon from '@mui/icons-material/Edit';
import AddIcon from '@mui/icons-material/Add'; import AddIcon from '@mui/icons-material/Add';
import usePagination from "shared/hooks/pagination";
export default function GroupListView(props) { export default function GroupListView(props) {
// meta
const {translate: L, requestModules, currentLocale} = useContext(LocaleContext); const {translate: L, requestModules, currentLocale} = useContext(LocaleContext);
const navigate = useNavigate(); const navigate = useNavigate();
const pagination = usePagination();
const api = props.api;
// data
const [groups, setGroups] = useState([]);
useEffect(() => { useEffect(() => {
requestModules(props.api, ["general", "account"], currentLocale).then(data => { requestModules(props.api, ["general", "account"], currentLocale).then(data => {
if (!data.success) { if (!data.success) {
alert(data.msg); props.showDialog(data.msg, "Error fetching localization");
} }
}); });
}, [currentLocale]); }, [currentLocale]);
const onFetchGroups = useCallback(async (page, count, orderBy, sortOrder) => { const onFetchGroups = useCallback(async (page, count, orderBy, sortOrder) => {
let res = await props.api.fetchGroups(page, count, orderBy, sortOrder);
api.fetchGroups(page, count, orderBy, sortOrder).then((res) => {
if (res.success) { if (res.success) {
return Promise.resolve([res.groups, res.pagination]); setGroups(res.groups);
pagination.update(res.pagination);
} else { } else {
props.showAlert("Error fetching groups", res.msg); props.showDialog(res.msg, "Error fetching groups");
return null; return null;
} }
}, []); });
}, [api, pagination]);
const actionColumn = (() => { const actionColumn = (() => {
let column = new DataColumn(L("general.actions"), null, false); let column = new DataColumn(L("general.actions"), null, false);
@ -69,9 +79,15 @@ export default function GroupListView(props) {
{L("general.create_new")} {L("general.create_new")}
</Button> </Button>
</Link> </Link>
<DataTable className={"table table-striped"} <DataTable
data={groups}
pagination={pagination}
defaultSortOrder={"asc"}
defaultSortColumn={0}
className={"table table-striped"}
fetchData={onFetchGroups} fetchData={onFetchGroups}
placeholder={"No groups to display"} placeholder={"No groups to display"}
title={L("account.groups")}
columns={columnDefinitions} /> columns={columnDefinitions} />
</div> </div>
</div> </div>

@ -123,8 +123,8 @@ export function DataTable(props) {
{title ? {title ?
<h3> <h3>
{fetchData ? {fetchData ?
<IconButton onClick={() => onFetchData(true)}> <IconButton onClick={() => onFetchData(true)} title={L("general.reload")}>
<CachedIcon/> <CachedIcon/>&nbsp;
</IconButton> </IconButton>
: <></> : <></>
} }

@ -50,6 +50,7 @@ export default function Dialog(props) {
switch (input.type) { switch (input.type) {
case 'label': case 'label':
delete inputProps.value;
inputElements.push(<span {...inputProps}>{input.value}</span>); inputElements.push(<span {...inputProps}>{input.value}</span>);
break; break;
case 'text': case 'text':
@ -57,11 +58,9 @@ export default function Dialog(props) {
inputElements.push(<TextField inputElements.push(<TextField
{...inputProps} {...inputProps}
type={input.type} type={input.type}
sx={{marginTop: 1}}
size={"small"} fullWidth={true} size={"small"} fullWidth={true}
key={"input-" + input.name} key={"input-" + input.name}
value={inputData[input.name] || ""} value={inputData[input.name] || ""}
defaultValue={input.defaultValue || ""}
onChange={e => setInputData({ ...inputData, [input.name]: e.target.value })} onChange={e => setInputData({ ...inputData, [input.name]: e.target.value })}
/>) />)
break; break;

@ -1,6 +1,7 @@
import React, {useState} from "react"; import React, {useState} from "react";
import {Box, MenuItem, Select, Pagination as MuiPagination} from "@mui/material"; import {Box, MenuItem, Select, Pagination as MuiPagination} from "@mui/material";
import {sprintf} from "sprintf-js"; import {sprintf} from "sprintf-js";
import {FormControl} from "@material-ui/core";
class Pagination { class Pagination {
@ -57,6 +58,7 @@ class Pagination {
options = options || [10, 25, 50, 100]; options = options || [10, 25, 50, 100];
return <Box display={"grid"} gridTemplateColumns={"75px auto"} className={"pagination-controls"}> return <Box display={"grid"} gridTemplateColumns={"75px auto"} className={"pagination-controls"}>
<FormControl>
<Select <Select
value={this.data.pageSize} value={this.data.pageSize}
className={"pagination-page-size"} className={"pagination-page-size"}
@ -66,6 +68,7 @@ class Pagination {
> >
{options.map(size => <MenuItem key={"size-" + size} value={size}>{size}</MenuItem>)} {options.map(size => <MenuItem key={"size-" + size} value={size}>{size}</MenuItem>)}
</Select> </Select>
</FormControl>
<MuiPagination <MuiPagination
count={this.getPageCount()} count={this.getPageCount()}
onChange={(_, page) => this.setPage(page)} onChange={(_, page) => this.setPage(page)}