mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 04:27:21 +00:00
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:
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,9 +104,23 @@ function AuthRolesEditInternal({ params }) {
|
||||
<IconButton Icon={TbDots} />
|
||||
</Dropdown>
|
||||
{!readonly && (
|
||||
<Button variant="primary" onClick={handleUpdate}>
|
||||
Update
|
||||
</Button>
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user