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

View File

@@ -1,6 +1,7 @@
import { cookieConfig, jwtConfig } from "auth/authenticate/Authenticator";
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";
export const Strategies = {
@@ -40,11 +41,8 @@ export type AppAuthCustomOAuthStrategy = s.Static<typeof STRATEGIES.custom_oauth
const guardConfigSchema = s.object({
enabled: s.boolean({ default: false }).optional(),
});
export const guardRoleSchema = s.strictObject({
permissions: s.array(s.string()).optional(),
is_default: s.boolean().optional(),
implicit_allow: s.boolean().optional(),
});
export const guardRoleSchema = roleSchema;
export const authConfigSchema = $object(
"config_auth",

View File

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

View File

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

View File

@@ -32,6 +32,7 @@ import { getVersion } from "core/env";
import type { Module } from "modules/Module";
import { getSystemMcp } from "modules/mcp/system-mcp";
import type { DbModuleManager } from "modules/db/DbModuleManager";
import type { TPermission } from "auth/authorize/Permission";
export type ConfigUpdate<Key extends ModuleKey = ModuleKey> = {
success: true;
@@ -46,7 +47,8 @@ export type SchemaResponse = {
schema: ModuleSchemas;
readonly: boolean;
config: ModuleConfigs;
permissions: string[];
//permissions: string[];
permissions: TPermission[];
};
export class SystemController extends Controller {
@@ -412,11 +414,24 @@ export class SystemController extends Controller {
readonly,
schema,
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(
"/build",
describeRoute({

View File

@@ -15,13 +15,14 @@ import { AppReduced } from "./utils/AppReduced";
import { Message } from "ui/components/display/Message";
import { useNavigate } from "ui/lib/routes";
import type { BkndAdminProps } from "ui/Admin";
import type { TPermission } from "auth/authorize/Permission";
export type BkndContext = {
version: number;
readonly: boolean;
schema: ModuleSchemas;
config: ModuleConfigs;
permissions: string[];
permissions: TPermission[];
hasSecrets: boolean;
requireSecrets: () => Promise<void>;
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 { Field } from "./Field";
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 = {
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 { 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 { 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 * 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 { routes, useNavigate } from "ui/lib/routes";
import { AuthRoleForm, type AuthRoleFormRef } from "ui/routes/auth/forms/role.form";
import { routes } from "ui/lib/routes";
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) {
useBrowserTitle(["Auth", "Roles", props.params.role]);
const { hasSecrets } = useBknd({ withSecrets: true });
if (!hasSecrets) {
return <Message.MissingPermission what="Roles & Permissions" />;
@@ -20,32 +41,46 @@ export function AuthRolesEdit(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 }) {
const [navigate] = useNavigate();
const { config, actions } = useBkndAuth();
const { config, schema: authSchema, actions } = useBkndAuth();
const roleName = params.role;
const role = config.roles?.[roleName];
const formRef = useRef<AuthRoleFormRef>(null);
const { readonly } = useBknd();
const schema = getSchema(authSchema);
async function handleUpdate() {
console.log("data", formRef.current?.isValid());
if (!formRef.current?.isValid()) return;
const data = formRef.current?.getData();
async function handleDelete() {}
async function handleUpdate(data: any) {
console.log("data", data);
const success = await actions.roles.patch(roleName, data);
if (success) {
console.log("success", success);
/* if (success) {
navigate(routes.auth.roles.list());
}
}
async function handleDelete() {
if (await actions.roles.delete(roleName)) {
navigate(routes.auth.roles.list());
}
} */
}
return (
<>
<Form schema={schema as any} initialValues={role} {...formConfig} onSubmit={handleUpdate}>
<AppShell.SectionHeader
right={
<>
@@ -69,10 +104,24 @@ function AuthRolesEditInternal({ params }) {
<IconButton Icon={TbDots} />
</Dropdown>
{!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
</Button>
)}
</Subscribe>
)}
</>
}
className="pl-3"
@@ -85,8 +134,120 @@ function AuthRolesEditInternal({ params }) {
/>
</AppShell.SectionHeader>
<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>
</>
</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 { routes, useNavigate } from "../../lib/routes";
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 { config, actions } = useBkndAuth();
const { readonly } = useBknd();
@@ -21,7 +34,7 @@ export function AuthRolesList() {
const data = Object.values(
transformObject(config.roles ?? {}, (role, name) => ({
role: name,
permissions: role.permissions,
permissions: role.permissions?.map((p) => p.permission) as string[],
is_default: role.is_default ?? false,
implicit_allow: role.implicit_allow ?? false,
})),

View File

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

View File

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

View File

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