role and permission handling in auth module

- Updated the `Role` class to change the `create` method signature for improved clarity and flexibility.
- Refactored the `guardRoleSchema` to utilize the new `roleSchema` for better consistency.
- Introduced a new `TPermission` type to enhance type safety in permission handling across the application.
- Updated various components and forms to accommodate the new permission structure, ensuring backward compatibility.
- Enhanced the `AuthRolesEdit` and `AuthRolesList` components to improve role management and permissions display.
- Added new API endpoints for fetching permissions, improving the overall functionality of the auth module.
This commit is contained in:
dswbx
2025-10-14 16:36:42 +02:00
parent 6624927286
commit 1b8ce41837
12 changed files with 254 additions and 52 deletions

View File

@@ -61,7 +61,7 @@ export class AppAuth extends Module<AppAuthSchema> {
// register roles // register roles
const roles = transformObject(this.config.roles ?? {}, (role, name) => { const roles = transformObject(this.config.roles ?? {}, (role, name) => {
return Role.create({ name, ...role }); return Role.create(name, role);
}); });
this.ctx.guard.setRoles(Object.values(roles)); this.ctx.guard.setRoles(Object.values(roles));
this.ctx.guard.setConfig(this.config.guard ?? {}); this.ctx.guard.setConfig(this.config.guard ?? {});
@@ -210,10 +210,13 @@ export class AppAuth extends Module<AppAuthSchema> {
} }
const strategies = this.authenticator.getStrategies(); const strategies = this.authenticator.getStrategies();
const roles = Object.fromEntries(this.ctx.guard.getRoles().map((r) => [r.name, r.toJSON()]));
console.log("roles", roles);
return { return {
...this.config, ...this.config,
...this.authenticator.toJSON(secrets), ...this.authenticator.toJSON(secrets),
roles: secrets ? roles : undefined,
strategies: transformObject(strategies, (strategy) => ({ strategies: transformObject(strategies, (strategy) => ({
enabled: this.isStrategyEnabled(strategy), enabled: this.isStrategyEnabled(strategy),
...strategy.toJSON(secrets), ...strategy.toJSON(secrets),

View File

@@ -1,6 +1,7 @@
import { cookieConfig, jwtConfig } from "auth/authenticate/Authenticator"; import { cookieConfig, jwtConfig } from "auth/authenticate/Authenticator";
import { CustomOAuthStrategy, OAuthStrategy, PasswordStrategy } from "auth/authenticate/strategies"; import { CustomOAuthStrategy, OAuthStrategy, PasswordStrategy } from "auth/authenticate/strategies";
import { objectTransform, s } from "bknd/utils"; import { roleSchema } from "auth/authorize/Role";
import { objectTransform, omitKeys, pick, s } from "bknd/utils";
import { $object, $record } from "modules/mcp"; import { $object, $record } from "modules/mcp";
export const Strategies = { export const Strategies = {
@@ -40,11 +41,8 @@ export type AppAuthCustomOAuthStrategy = s.Static<typeof STRATEGIES.custom_oauth
const guardConfigSchema = s.object({ const guardConfigSchema = s.object({
enabled: s.boolean({ default: false }).optional(), enabled: s.boolean({ default: false }).optional(),
}); });
export const guardRoleSchema = s.strictObject({
permissions: s.array(s.string()).optional(), export const guardRoleSchema = roleSchema;
is_default: s.boolean().optional(),
implicit_allow: s.boolean().optional(),
});
export const authConfigSchema = $object( export const authConfigSchema = $object(
"config_auth", "config_auth",

View File

@@ -7,6 +7,13 @@ export const permissionOptionsSchema = s
}) })
.partial(); .partial();
export type TPermission = {
name: string;
description?: string;
filterable?: boolean;
context?: any;
};
export type PermissionOptions = s.Static<typeof permissionOptionsSchema>; export type PermissionOptions = s.Static<typeof permissionOptionsSchema>;
export type PermissionContext<P extends Permission<any, any, any, any>> = P extends Permission< export type PermissionContext<P extends Permission<any, any, any, any>> = P extends Permission<
any, any,

View File

@@ -13,7 +13,7 @@ export const rolePermissionSchema = s.strictObject({
export type RolePermissionSchema = s.Static<typeof rolePermissionSchema>; export type RolePermissionSchema = s.Static<typeof rolePermissionSchema>;
export const roleSchema = s.strictObject({ export const roleSchema = s.strictObject({
name: s.string(), // @todo: remove anyOf, add migration
permissions: s.anyOf([s.array(s.string()), s.array(rolePermissionSchema)]).optional(), permissions: s.anyOf([s.array(s.string()), s.array(rolePermissionSchema)]).optional(),
is_default: s.boolean().optional(), is_default: s.boolean().optional(),
implicit_allow: s.boolean().optional(), implicit_allow: s.boolean().optional(),
@@ -44,7 +44,7 @@ export class Role {
public implicit_allow: boolean = false, public implicit_allow: boolean = false,
) {} ) {}
static create(config: RoleSchema) { static create(name: string, config: RoleSchema) {
const permissions = const permissions =
config.permissions?.map((p: string | RolePermissionSchema) => { config.permissions?.map((p: string | RolePermissionSchema) => {
if (typeof p === "string") { if (typeof p === "string") {
@@ -53,12 +53,11 @@ export class Role {
const policies = p.policies?.map((policy) => new Policy(policy)); const policies = p.policies?.map((policy) => new Policy(policy));
return new RolePermission(new Permission(p.permission), policies, p.effect); return new RolePermission(new Permission(p.permission), policies, p.effect);
}) ?? []; }) ?? [];
return new Role(config.name, permissions, config.is_default, config.implicit_allow); return new Role(name, permissions, config.is_default, config.implicit_allow);
} }
toJSON() { toJSON() {
return { return {
name: this.name,
permissions: this.permissions.map((p) => p.toJSON()), permissions: this.permissions.map((p) => p.toJSON()),
is_default: this.is_default, is_default: this.is_default,
implicit_allow: this.implicit_allow, implicit_allow: this.implicit_allow,

View File

@@ -32,6 +32,7 @@ import { getVersion } from "core/env";
import type { Module } from "modules/Module"; import type { Module } from "modules/Module";
import { getSystemMcp } from "modules/mcp/system-mcp"; import { getSystemMcp } from "modules/mcp/system-mcp";
import type { DbModuleManager } from "modules/db/DbModuleManager"; import type { DbModuleManager } from "modules/db/DbModuleManager";
import type { TPermission } from "auth/authorize/Permission";
export type ConfigUpdate<Key extends ModuleKey = ModuleKey> = { export type ConfigUpdate<Key extends ModuleKey = ModuleKey> = {
success: true; success: true;
@@ -46,7 +47,8 @@ export type SchemaResponse = {
schema: ModuleSchemas; schema: ModuleSchemas;
readonly: boolean; readonly: boolean;
config: ModuleConfigs; config: ModuleConfigs;
permissions: string[]; //permissions: string[];
permissions: TPermission[];
}; };
export class SystemController extends Controller { export class SystemController extends Controller {
@@ -412,11 +414,24 @@ export class SystemController extends Controller {
readonly, readonly,
schema, schema,
config: config ? this.app.toJSON(secrets) : undefined, config: config ? this.app.toJSON(secrets) : undefined,
permissions: this.app.modules.ctx().guard.getPermissionNames(), permissions: this.app.modules.ctx().guard.getPermissions(),
//permissions: this.app.modules.ctx().guard.getPermissionNames(),
}); });
}, },
); );
hono.get(
"/permissions",
describeRoute({
summary: "Get the permissions",
tags: ["system"],
}),
(c) => {
const permissions = this.app.modules.ctx().guard.getPermissions();
return c.json({ permissions });
},
);
hono.post( hono.post(
"/build", "/build",
describeRoute({ describeRoute({

View File

@@ -15,13 +15,14 @@ import { AppReduced } from "./utils/AppReduced";
import { Message } from "ui/components/display/Message"; import { Message } from "ui/components/display/Message";
import { useNavigate } from "ui/lib/routes"; import { useNavigate } from "ui/lib/routes";
import type { BkndAdminProps } from "ui/Admin"; import type { BkndAdminProps } from "ui/Admin";
import type { TPermission } from "auth/authorize/Permission";
export type BkndContext = { export type BkndContext = {
version: number; version: number;
readonly: boolean; readonly: boolean;
schema: ModuleSchemas; schema: ModuleSchemas;
config: ModuleConfigs; config: ModuleConfigs;
permissions: string[]; permissions: TPermission[];
hasSecrets: boolean; hasSecrets: boolean;
requireSecrets: () => Promise<void>; requireSecrets: () => Promise<void>;
actions: ReturnType<typeof getSchemaActions>; actions: ReturnType<typeof getSchemaActions>;

View File

@@ -2,7 +2,8 @@ import { isTypeSchema } from "ui/components/form/json-schema-form/utils";
import { AnyOfField } from "./AnyOfField"; import { AnyOfField } from "./AnyOfField";
import { Field } from "./Field"; import { Field } from "./Field";
import { FieldWrapper, type FieldwrapperProps } from "./FieldWrapper"; import { FieldWrapper, type FieldwrapperProps } from "./FieldWrapper";
import { type JSONSchema, useDerivedFieldContext } from "./Form"; import { type JSONSchema, useDerivedFieldContext, useFormValue } from "./Form";
import { JsonEditor } from "ui/components/code/JsonEditor";
export type ObjectFieldProps = { export type ObjectFieldProps = {
path?: string; path?: string;

View File

@@ -1,17 +1,38 @@
import { useRef } from "react";
import { TbDots } from "react-icons/tb";
import { useBknd } from "ui/client/bknd"; import { useBknd } from "ui/client/bknd";
import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
import { Button } from "ui/components/buttons/Button";
import { IconButton } from "ui/components/buttons/IconButton";
import { Message } from "ui/components/display/Message"; import { Message } from "ui/components/display/Message";
import { useBkndAuth } from "ui/client/schema/auth/use-bknd-auth";
import { useBrowserTitle } from "ui/hooks/use-browser-title";
import { useRef, useState } from "react";
import { useNavigate } from "ui/lib/routes";
import { isDebug } from "core/env";
import { Dropdown } from "ui/components/overlay/Dropdown"; import { Dropdown } from "ui/components/overlay/Dropdown";
import * as AppShell from "ui/layouts/AppShell/AppShell"; import { IconButton } from "ui/components/buttons/IconButton";
import { TbAdjustments, TbDots, TbLock, TbLockOpen, TbLockOpen2 } from "react-icons/tb";
import { Button } from "ui/components/buttons/Button";
import { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2"; import { Breadcrumbs2 } from "ui/layouts/AppShell/Breadcrumbs2";
import { routes, useNavigate } from "ui/lib/routes"; import { routes } from "ui/lib/routes";
import { AuthRoleForm, type AuthRoleFormRef } from "ui/routes/auth/forms/role.form"; import * as AppShell from "ui/layouts/AppShell/AppShell";
import * as Formy from "ui/components/form/Formy";
import { ucFirst, type s } from "bknd/utils";
import type { ModuleSchemas } from "bknd";
import {
ArrayField,
Field,
Form,
FormDebug,
Subscribe,
useFormContext,
useFormValue,
} from "ui/components/form/json-schema-form";
import type { TPermission } from "auth/authorize/Permission";
import type { RoleSchema } from "auth/authorize/Role";
import { SegmentedControl, Tooltip } from "@mantine/core";
import { cn } from "ui/lib/utils";
export function AuthRolesEdit(props) { export function AuthRolesEdit(props) {
useBrowserTitle(["Auth", "Roles", props.params.role]);
const { hasSecrets } = useBknd({ withSecrets: true }); const { hasSecrets } = useBknd({ withSecrets: true });
if (!hasSecrets) { if (!hasSecrets) {
return <Message.MissingPermission what="Roles & Permissions" />; return <Message.MissingPermission what="Roles & Permissions" />;
@@ -20,32 +41,46 @@ export function AuthRolesEdit(props) {
return <AuthRolesEditInternal {...props} />; return <AuthRolesEditInternal {...props} />;
} }
// currently for backward compatibility
function getSchema(authSchema: ModuleSchemas["auth"]) {
const roles = authSchema.properties.roles.additionalProperties;
return {
...roles,
properties: {
...roles.properties,
permissions: {
...roles.properties.permissions.anyOf[1],
},
},
};
}
const formConfig = {
options: {
debug: isDebug(),
},
};
function AuthRolesEditInternal({ params }) { function AuthRolesEditInternal({ params }) {
const [navigate] = useNavigate(); const [navigate] = useNavigate();
const { config, actions } = useBkndAuth(); const { config, schema: authSchema, actions } = useBkndAuth();
const roleName = params.role; const roleName = params.role;
const role = config.roles?.[roleName]; const role = config.roles?.[roleName];
const formRef = useRef<AuthRoleFormRef>(null);
const { readonly } = useBknd(); const { readonly } = useBknd();
const schema = getSchema(authSchema);
async function handleUpdate() { async function handleDelete() {}
console.log("data", formRef.current?.isValid()); async function handleUpdate(data: any) {
if (!formRef.current?.isValid()) return; console.log("data", data);
const data = formRef.current?.getData();
const success = await actions.roles.patch(roleName, data); const success = await actions.roles.patch(roleName, data);
if (success) { console.log("success", success);
/* if (success) {
navigate(routes.auth.roles.list()); navigate(routes.auth.roles.list());
} } */
}
async function handleDelete() {
if (await actions.roles.delete(roleName)) {
navigate(routes.auth.roles.list());
}
} }
return ( return (
<> <Form schema={schema as any} initialValues={role} {...formConfig} onSubmit={handleUpdate}>
<AppShell.SectionHeader <AppShell.SectionHeader
right={ right={
<> <>
@@ -69,10 +104,24 @@ function AuthRolesEditInternal({ params }) {
<IconButton Icon={TbDots} /> <IconButton Icon={TbDots} />
</Dropdown> </Dropdown>
{!readonly && ( {!readonly && (
<Button variant="primary" onClick={handleUpdate}> <Subscribe
selector={(state) => ({
dirty: state.dirty,
errors: state.errors.length > 0,
submitting: state.submitting,
})}
>
{({ dirty, errors, submitting }) => (
<Button
variant="primary"
type="submit"
disabled={!dirty || errors || submitting}
>
Update Update
</Button> </Button>
)} )}
</Subscribe>
)}
</> </>
} }
className="pl-3" className="pl-3"
@@ -85,8 +134,120 @@ function AuthRolesEditInternal({ params }) {
/> />
</AppShell.SectionHeader> </AppShell.SectionHeader>
<AppShell.Scrollable> <AppShell.Scrollable>
<AuthRoleForm ref={formRef} role={role} /> <div className="flex flex-col flex-grow px-5 py-5 gap-8">
<div className="flex flex-col gap-2">
<Permissions />
</div>
<div className="flex flex-col gap-4">
<Field
label="Should this role be the default?"
name="is_default"
description="In case an user is not assigned any role, this role will be assigned by default."
descriptionPlacement="top"
/>
<Field
label="Implicit allow missing permissions?"
name="implicit_allow"
description="This should be only used for admins. If a permission is not explicitly denied, it will be allowed."
descriptionPlacement="top"
/>
</div>
</div>
<FormDebug />
</AppShell.Scrollable> </AppShell.Scrollable>
</> </Form>
); );
} }
type PermissionsData = Exclude<RoleSchema["permissions"], string[] | undefined>;
type PermissionData = PermissionsData[number];
const Permissions = () => {
const { permissions } = useBknd();
const grouped = permissions.reduce(
(acc, permission, index) => {
const [group, name] = permission.name.split(".") as [string, string];
if (!acc[group]) acc[group] = [];
acc[group].push({ index, permission });
return acc;
},
{} as Record<string, { index: number; permission: TPermission }[]>,
);
return (
<div className="flex flex-col gap-10">
{Object.entries(grouped).map(([group, rows]) => {
return (
<div className="flex flex-col gap-2" key={group}>
<h3 className="font-semibold">{ucFirst(group)} Permissions</h3>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-3 items-start">
{rows.map(({ index, permission }) => (
<Permission key={permission.name} permission={permission} index={index} />
))}
</div>
</div>
);
})}
</div>
);
};
const Permission = ({ permission, index }: { permission: TPermission; index?: number }) => {
const path = `permissions.${index}`;
const { value } = useFormValue(path);
const { setValue, deleteValue } = useFormContext();
const [open, setOpen] = useState(false);
const data = value as PermissionData | undefined;
async function handleSwitch() {
if (data) {
deleteValue(path);
} else {
setValue(path, {
permission: permission.name,
policies: [],
effect: "allow",
});
}
}
return (
<>
<div
key={permission.name}
className={cn("flex flex-col border border-muted", open && "border-primary/20")}
>
<div className={cn("flex flex-row gap-2 justify-between", open && "bg-primary/5")}>
<div className="py-4 px-4 font-mono leading-none">{permission.name}</div>
<div className="flex flex-row gap-1 items-center px-2">
<Formy.Switch size="sm" checked={!!data} onChange={handleSwitch} />
<Tooltip label="Customize" disabled>
<IconButton
size="md"
variant="ghost"
disabled={!data}
Icon={TbAdjustments}
className="disabled:opacity-20"
onClick={() => setOpen((o) => !o)}
/>
</Tooltip>
</div>
</div>
{open && (
<div className="px-3.5 py-3.5">
<ArrayField
path={`permissions.${index}.policies`}
labelAdd="Add Policy"
wrapperProps={{
label: false,
wrapper: "group",
}}
/>
</div>
)}
</div>
</>
);
};

View File

@@ -12,8 +12,21 @@ import { CellValue, DataTable } from "../../components/table/DataTable";
import * as AppShell from "../../layouts/AppShell/AppShell"; import * as AppShell from "../../layouts/AppShell/AppShell";
import { routes, useNavigate } from "../../lib/routes"; import { routes, useNavigate } from "../../lib/routes";
import { useBknd } from "ui/client/bknd"; import { useBknd } from "ui/client/bknd";
import { useBrowserTitle } from "ui/hooks/use-browser-title";
import { Message } from "ui/components/display/Message";
export function AuthRolesList() { export function AuthRolesList(props) {
useBrowserTitle(["Auth", "Roles"]);
const { hasSecrets } = useBknd({ withSecrets: true });
if (!hasSecrets) {
return <Message.MissingPermission what="Auth Roles" />;
}
return <AuthRolesListInternal {...props} />;
}
function AuthRolesListInternal() {
const [navigate] = useNavigate(); const [navigate] = useNavigate();
const { config, actions } = useBkndAuth(); const { config, actions } = useBkndAuth();
const { readonly } = useBknd(); const { readonly } = useBknd();
@@ -21,7 +34,7 @@ export function AuthRolesList() {
const data = Object.values( const data = Object.values(
transformObject(config.roles ?? {}, (role, name) => ({ transformObject(config.roles ?? {}, (role, name) => ({
role: name, role: name,
permissions: role.permissions, permissions: role.permissions?.map((p) => p.permission) as string[],
is_default: role.is_default ?? false, is_default: role.is_default ?? false,
implicit_allow: role.implicit_allow ?? false, implicit_allow: role.implicit_allow ?? false,
})), })),

View File

@@ -34,7 +34,11 @@ export const AuthRoleForm = forwardRef<
getValues, getValues,
} = useForm({ } = useForm({
resolver: standardSchemaResolver(schema), resolver: standardSchemaResolver(schema),
defaultValues: role, defaultValues: {
...role,
// compat
permissions: role?.permissions?.map((p) => p.permission),
},
}); });
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
@@ -47,7 +51,7 @@ export const AuthRoleForm = forwardRef<
<div className="flex flex-col flex-grow px-5 py-5 gap-8"> <div className="flex flex-col flex-grow px-5 py-5 gap-8">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{/*<h3 className="font-semibold">Role Permissions</h3>*/} {/*<h3 className="font-semibold">Role Permissions</h3>*/}
<Permissions control={control} permissions={permissions} /> <Permissions control={control} permissions={permissions.map((p) => p.name)} />
</div> </div>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<Input.Wrapper <Input.Wrapper
@@ -111,8 +115,6 @@ const Permissions = ({
{} as Record<string, string[]>, {} as Record<string, string[]>,
); );
console.log("grouped", grouped);
//console.log("fieldState", fieldState, value);
return ( return (
<div className="flex flex-col gap-10"> <div className="flex flex-col gap-10">
{Object.entries(grouped).map(([group, permissions]) => { {Object.entries(grouped).map(([group, permissions]) => {
@@ -121,7 +123,7 @@ const Permissions = ({
<h3 className="font-semibold">{ucFirst(group)} Permissions</h3> <h3 className="font-semibold">{ucFirst(group)} Permissions</h3>
<div className="grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-3 gap-3"> <div className="grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-3 gap-3">
{permissions.map((permission) => { {permissions.map((permission) => {
const selected = data.includes(permission); const selected = data.includes(permission as any);
return ( return (
<div key={permission} className="flex flex-col border border-muted"> <div key={permission} className="flex flex-col border border-muted">
<div className="flex flex-row gap-2 justify-between"> <div className="flex flex-row gap-2 justify-between">

View File

@@ -63,10 +63,10 @@ export const AuthSettings = ({ schema: _unsafe_copy, config }) => {
} catch (e) {} } catch (e) {}
console.log("_s", _s); console.log("_s", _s);
const roleSchema = _schema.properties.roles?.additionalProperties ?? { type: "object" }; const roleSchema = _schema.properties.roles?.additionalProperties ?? { type: "object" };
if (_s.permissions) { /* if (_s.permissions) {
roleSchema.properties.permissions.items.enum = _s.permissions; roleSchema.properties.permissions.items.enum = _s.permissions;
roleSchema.properties.permissions.uniqueItems = true; roleSchema.properties.permissions.uniqueItems = true;
} } */
return ( return (
<Route path="/auth" nest> <Route path="/auth" nest>

View File

@@ -27,6 +27,7 @@ import SortableTest from "./tests/sortable-test";
import { SqlAiTest } from "./tests/sql-ai-test"; import { SqlAiTest } from "./tests/sql-ai-test";
import Themes from "./tests/themes"; import Themes from "./tests/themes";
import ErrorBoundary from "ui/components/display/ErrorBoundary"; import ErrorBoundary from "ui/components/display/ErrorBoundary";
import CodeEditorTest from "./tests/code-editor-test";
const tests = { const tests = {
DropdownTest, DropdownTest,
@@ -52,6 +53,7 @@ const tests = {
JsonSchemaForm3, JsonSchemaForm3,
FormyTest, FormyTest,
HtmlFormTest, HtmlFormTest,
CodeEditorTest,
} as const; } as const;
export default function TestRoutes() { export default function TestRoutes() {