forked from bai/curriculum-project-hub
feat: add organization tenant model
This commit is contained in:
@@ -1,6 +1,12 @@
|
||||
import { afterAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { prisma, resetDb } from "./helpers.js";
|
||||
import { createPermissionAuthorizer, PrismaPrincipalResolver, syncExternalPrincipalMemberships } from "../../src/permission.js";
|
||||
import { DEFAULT_ORG_ID, prisma, resetDb, seedTestOrganization } from "./helpers.js";
|
||||
import {
|
||||
createPermissionAuthorizer,
|
||||
grantTeamProjectAccess,
|
||||
listProjectTeamAccess,
|
||||
PrismaPrincipalResolver,
|
||||
syncExternalPrincipalMemberships,
|
||||
} from "../../src/permission.js";
|
||||
|
||||
describe("ADR-0019 authorization", () => {
|
||||
beforeEach(async () => {
|
||||
@@ -16,23 +22,30 @@ describe("ADR-0019 authorization", () => {
|
||||
data: { id: "u-auth-1", feishuOpenId: "ou_auth_1", displayName: "Teacher A" },
|
||||
});
|
||||
const directTeam = await prisma.team.create({
|
||||
data: { slug: "direct-auth", name: "Direct Team" },
|
||||
data: { organizationId: DEFAULT_ORG_ID, slug: "direct-auth", name: "Direct Team" },
|
||||
});
|
||||
const departmentTeam = await prisma.team.create({
|
||||
data: { slug: "department-auth", name: "Department Team" },
|
||||
data: { organizationId: DEFAULT_ORG_ID, slug: "department-auth", name: "Department Team" },
|
||||
});
|
||||
const chatTeam = await prisma.team.create({
|
||||
data: { slug: "chat-auth", name: "Chat Team" },
|
||||
data: { organizationId: DEFAULT_ORG_ID, slug: "chat-auth", name: "Chat Team" },
|
||||
});
|
||||
await prisma.teamMembership.create({
|
||||
data: { teamId: directTeam.id, userId: user.id },
|
||||
});
|
||||
const connection = await prisma.externalDirectoryConnection.create({
|
||||
data: {
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
provider: "FEISHU",
|
||||
source: "test",
|
||||
},
|
||||
});
|
||||
await prisma.externalPrincipalMembership.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
connectionId: connection.id,
|
||||
principalType: "FEISHU_DEPARTMENT",
|
||||
principalId: "dep-physics",
|
||||
source: "test",
|
||||
},
|
||||
});
|
||||
await prisma.teamExternalBinding.create({
|
||||
@@ -45,7 +58,7 @@ describe("ADR-0019 authorization", () => {
|
||||
const resolution = await new PrismaPrincipalResolver(prisma).resolveActor({
|
||||
feishuOpenId: "ou_auth_1",
|
||||
chatId: "chat-auth",
|
||||
});
|
||||
}, { organizationId: DEFAULT_ORG_ID });
|
||||
|
||||
expect(resolution.userId).toBe(user.id);
|
||||
expect(resolution.principals).toEqual(expect.arrayContaining([
|
||||
@@ -85,9 +98,10 @@ describe("ADR-0019 authorization", () => {
|
||||
|
||||
it("uses external Feishu principal memberships as grantable principals", async () => {
|
||||
await prisma.project.create({
|
||||
data: { id: "p-auth-ext", name: "External", workspaceDir: "/tmp/auth-ext" },
|
||||
data: { id: "p-auth-ext", organizationId: DEFAULT_ORG_ID, name: "External", workspaceDir: "/tmp/auth-ext" },
|
||||
});
|
||||
await syncExternalPrincipalMemberships(prisma, {
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
source: "feishu-test",
|
||||
memberships: [
|
||||
{
|
||||
@@ -123,6 +137,7 @@ describe("ADR-0019 authorization", () => {
|
||||
|
||||
it("revokes missing external memberships when replacing a sync source", async () => {
|
||||
await syncExternalPrincipalMemberships(prisma, {
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
source: "feishu-directory",
|
||||
memberships: [
|
||||
{
|
||||
@@ -141,6 +156,7 @@ describe("ADR-0019 authorization", () => {
|
||||
});
|
||||
|
||||
const result = await syncExternalPrincipalMemberships(prisma, {
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
source: "feishu-directory",
|
||||
replaceSource: true,
|
||||
memberships: [
|
||||
@@ -154,13 +170,16 @@ describe("ADR-0019 authorization", () => {
|
||||
});
|
||||
|
||||
expect(result).toEqual({ synced: 1, revoked: 1 });
|
||||
const activeB = await new PrismaPrincipalResolver(prisma).resolveActor({ feishuOpenId: "ou_sync_b" });
|
||||
const activeB = await new PrismaPrincipalResolver(prisma).resolveActor(
|
||||
{ feishuOpenId: "ou_sync_b" },
|
||||
{ organizationId: DEFAULT_ORG_ID },
|
||||
);
|
||||
expect(activeB.principals).not.toContainEqual({ type: "FEISHU_DEPARTMENT", id: "dep-b" });
|
||||
});
|
||||
|
||||
it("lets PermissionSettings constrain agent trigger without granting by itself", async () => {
|
||||
await prisma.project.create({
|
||||
data: { id: "p-auth-policy", name: "Policy", workspaceDir: "/tmp/auth-policy" },
|
||||
data: { id: "p-auth-policy", organizationId: DEFAULT_ORG_ID, name: "Policy", workspaceDir: "/tmp/auth-policy" },
|
||||
});
|
||||
await prisma.user.create({
|
||||
data: { id: "u-auth-policy", feishuOpenId: "ou_auth_policy", displayName: "Teacher Policy" },
|
||||
@@ -185,12 +204,15 @@ describe("ADR-0019 authorization", () => {
|
||||
});
|
||||
expect(editDecision.allowed).toBe(false);
|
||||
|
||||
await prisma.permissionGrant.create({
|
||||
data: {
|
||||
await prisma.permissionGrant.updateMany({
|
||||
where: {
|
||||
resourceType: "PROJECT",
|
||||
resourceId: "p-auth-policy",
|
||||
principalType: "USER",
|
||||
principalId: "ou_auth_policy",
|
||||
revokedAt: null,
|
||||
},
|
||||
data: {
|
||||
role: "MANAGE",
|
||||
},
|
||||
});
|
||||
@@ -253,7 +275,7 @@ describe("ADR-0019 authorization", () => {
|
||||
|
||||
it("keeps a role configured when all role grants are revoked", async () => {
|
||||
await prisma.project.create({
|
||||
data: { id: "p-auth-revoked-role", name: "Revoked", workspaceDir: "/tmp/auth-revoked" },
|
||||
data: { id: "p-auth-revoked-role", organizationId: DEFAULT_ORG_ID, name: "Revoked", workspaceDir: "/tmp/auth-revoked" },
|
||||
});
|
||||
await prisma.user.create({
|
||||
data: { id: "u-auth-revoked-role", feishuOpenId: "ou_auth_revoked", displayName: "Teacher Revoked" },
|
||||
@@ -288,7 +310,7 @@ describe("ADR-0019 authorization", () => {
|
||||
expect(decision.reason).toContain("no active role review grant");
|
||||
});
|
||||
it("agent.cancel defaults to MANAGE and is allowed for a manage grant", async () => {
|
||||
await prisma.project.create({ data: { id: "p-cancel", name: "Cancel", workspaceDir: "/tmp/cancel" } });
|
||||
await prisma.project.create({ data: { id: "p-cancel", organizationId: DEFAULT_ORG_ID, name: "Cancel", workspaceDir: "/tmp/cancel" } });
|
||||
await prisma.user.create({ data: { id: "u-cancel", feishuOpenId: "ou_cancel", displayName: "Manager" } });
|
||||
await prisma.permissionGrant.create({
|
||||
data: { resourceType: "PROJECT", resourceId: "p-cancel", principalType: "USER", principalId: "ou_cancel", role: "MANAGE" },
|
||||
@@ -304,7 +326,7 @@ describe("ADR-0019 authorization", () => {
|
||||
});
|
||||
|
||||
it("agent.cancel is denied to an edit grant (below the MANAGE default)", async () => {
|
||||
await prisma.project.create({ data: { id: "p-cancel-edit", name: "Cancel Edit", workspaceDir: "/tmp/cancel-edit" } });
|
||||
await prisma.project.create({ data: { id: "p-cancel-edit", organizationId: DEFAULT_ORG_ID, name: "Cancel Edit", workspaceDir: "/tmp/cancel-edit" } });
|
||||
await prisma.user.create({ data: { id: "u-cancel-edit", feishuOpenId: "ou_cancel_edit", displayName: "Editor" } });
|
||||
await prisma.permissionGrant.create({
|
||||
data: { resourceType: "PROJECT", resourceId: "p-cancel-edit", principalType: "USER", principalId: "ou_cancel_edit", role: "EDIT" },
|
||||
@@ -319,7 +341,7 @@ describe("ADR-0019 authorization", () => {
|
||||
});
|
||||
|
||||
it("agentCancel DISABLED policy denies cancel even for a manage grant", async () => {
|
||||
await prisma.project.create({ data: { id: "p-cancel-off", name: "Cancel Off", workspaceDir: "/tmp/cancel-off" } });
|
||||
await prisma.project.create({ data: { id: "p-cancel-off", organizationId: DEFAULT_ORG_ID, name: "Cancel Off", workspaceDir: "/tmp/cancel-off" } });
|
||||
await prisma.user.create({ data: { id: "u-cancel-off", feishuOpenId: "ou_cancel_off", displayName: "Manager Off" } });
|
||||
await prisma.permissionGrant.create({
|
||||
data: { resourceType: "PROJECT", resourceId: "p-cancel-off", principalType: "USER", principalId: "ou_cancel_off", role: "MANAGE" },
|
||||
@@ -336,6 +358,57 @@ describe("ADR-0019 authorization", () => {
|
||||
expect(decision.allowed).toBe(false);
|
||||
expect(decision.reason).toContain("disables this action");
|
||||
});
|
||||
|
||||
it("scopes team slug and project team access by organization", async () => {
|
||||
await seedTestOrganization("org-other", "other");
|
||||
await prisma.team.create({
|
||||
data: { organizationId: "org-other", slug: "curriculum", name: "Other Curriculum" },
|
||||
});
|
||||
const team = await prisma.team.create({
|
||||
data: { organizationId: DEFAULT_ORG_ID, slug: "curriculum", name: "Default Curriculum" },
|
||||
});
|
||||
await prisma.project.create({
|
||||
data: { id: "p-team-access", organizationId: DEFAULT_ORG_ID, name: "Team Access", workspaceDir: "/tmp/team-access" },
|
||||
});
|
||||
|
||||
const entry = await grantTeamProjectAccess(prisma, {
|
||||
projectId: "p-team-access",
|
||||
teamSlug: "curriculum",
|
||||
role: "EDIT",
|
||||
});
|
||||
|
||||
expect(entry).toMatchObject({
|
||||
projectId: "p-team-access",
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
teamId: team.id,
|
||||
teamSlug: "curriculum",
|
||||
role: "EDIT",
|
||||
});
|
||||
await grantTeamProjectAccess(prisma, {
|
||||
projectId: "p-team-access",
|
||||
teamSlug: "curriculum",
|
||||
role: "MANAGE",
|
||||
});
|
||||
const entries = await listProjectTeamAccess(prisma, "p-team-access");
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0]).toMatchObject({ teamId: team.id, role: "MANAGE" });
|
||||
});
|
||||
|
||||
it("refuses cross-organization team project grants", async () => {
|
||||
await seedTestOrganization("org-other-deny", "other-deny");
|
||||
const otherTeam = await prisma.team.create({
|
||||
data: { organizationId: "org-other-deny", slug: "review", name: "Other Review" },
|
||||
});
|
||||
await prisma.project.create({
|
||||
data: { id: "p-cross-org", organizationId: DEFAULT_ORG_ID, name: "Cross Org", workspaceDir: "/tmp/cross-org" },
|
||||
});
|
||||
|
||||
await expect(grantTeamProjectAccess(prisma, {
|
||||
projectId: "p-cross-org",
|
||||
teamId: otherTeam.id,
|
||||
role: "EDIT",
|
||||
})).rejects.toThrow(/cross-organization team grant refused/);
|
||||
});
|
||||
});
|
||||
|
||||
async function seedUserTeamProject(suffix: string) {
|
||||
@@ -347,13 +420,13 @@ async function seedUserTeamProject(suffix: string) {
|
||||
},
|
||||
});
|
||||
const team = await prisma.team.create({
|
||||
data: { slug: `team-${suffix}`, name: `Team ${suffix}` },
|
||||
data: { organizationId: DEFAULT_ORG_ID, slug: `team-${suffix}`, name: `Team ${suffix}` },
|
||||
});
|
||||
await prisma.teamMembership.create({
|
||||
data: { teamId: team.id, userId: user.id },
|
||||
});
|
||||
const project = await prisma.project.create({
|
||||
data: { id: `p-${suffix}`, name: `Project ${suffix}`, workspaceDir: `/tmp/${suffix}` },
|
||||
data: { id: `p-${suffix}`, organizationId: DEFAULT_ORG_ID, name: `Project ${suffix}`, workspaceDir: `/tmp/${suffix}` },
|
||||
});
|
||||
return { user, team, project };
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { FeishuRuntime } from "../../src/feishu/client.js";
|
||||
import type { ModelFactory } from "../../src/agent/runner.js";
|
||||
|
||||
export const TEST_DATABASE_URL = "postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test";
|
||||
export const DEFAULT_ORG_ID = "org_test_default";
|
||||
|
||||
export const prisma = new PrismaClient({
|
||||
datasources: { db: { url: TEST_DATABASE_URL } },
|
||||
@@ -35,6 +36,7 @@ export async function resetDb(): Promise<void> {
|
||||
"PermissionGrant",
|
||||
"RoleTriggerGrant",
|
||||
"ExternalPrincipalMembership",
|
||||
"ExternalDirectoryConnection",
|
||||
"TeamExternalBinding",
|
||||
"TeamMembership",
|
||||
"Team",
|
||||
@@ -42,12 +44,30 @@ export async function resetDb(): Promise<void> {
|
||||
"AgentRun",
|
||||
"AgentSession",
|
||||
"ProjectGroupBinding",
|
||||
"OrganizationMembership",
|
||||
"PlatformRoleAssignment",
|
||||
"User",
|
||||
"Project",
|
||||
"Organization",
|
||||
];
|
||||
// Truncate with CASCADE to wipe dependent rows in one shot.
|
||||
await prisma.$executeRawUnsafe(`TRUNCATE TABLE ${tables.map((t) => `"${t}"`).join(", ")} RESTART IDENTITY CASCADE`);
|
||||
await seedTestOrganization();
|
||||
}
|
||||
|
||||
export async function seedTestOrganization(
|
||||
id: string = DEFAULT_ORG_ID,
|
||||
slug: string = "test-default",
|
||||
): Promise<void> {
|
||||
await prisma.organization.upsert({
|
||||
where: { id },
|
||||
update: {},
|
||||
create: {
|
||||
id,
|
||||
slug,
|
||||
name: "Test Default Organization",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** A logger that discards everything (tests don't need fastify's pino). */
|
||||
@@ -340,6 +360,7 @@ export async function seedProject(
|
||||
await prisma.project.create({
|
||||
data: {
|
||||
id: projectId,
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
name: `Test ${projectId}`,
|
||||
workspaceDir: `/tmp/test-${projectId}`,
|
||||
},
|
||||
@@ -350,6 +371,7 @@ export async function seedProject(
|
||||
feishuOpenId: principal,
|
||||
displayName: "Test User",
|
||||
platformRoles: { create: { role: "TEACHER" } },
|
||||
organizationMemberships: { create: { organizationId: DEFAULT_ORG_ID, role: "MEMBER" } },
|
||||
permissionGrants: {
|
||||
create: {
|
||||
resourceType: "PROJECT",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach, afterAll } from "vitest";
|
||||
import { prisma, resetDb } from "./helpers.js";
|
||||
import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js";
|
||||
import { acquireLock, releaseLock, currentLockRunId, isTerminal } from "../../src/lock.js";
|
||||
|
||||
describe("ProjectAgentLock WellFormed (integration, ADR-0002)", () => {
|
||||
@@ -9,7 +9,7 @@ describe("ProjectAgentLock WellFormed (integration, ADR-0002)", () => {
|
||||
|
||||
it("acquireLock + currentLockRunId round-trip", async () => {
|
||||
const project = await prisma.project.create({
|
||||
data: { id: "p-lock-1", name: "Test", workspaceDir: "/tmp/x" },
|
||||
data: { id: "p-lock-1", organizationId: DEFAULT_ORG_ID, name: "Test", workspaceDir: "/tmp/x" },
|
||||
});
|
||||
const run = await prisma.agentRun.create({
|
||||
data: { projectId: project.id, entrypoint: "FEISHU", status: "ACTIVE", prompt: "x", model: "m", provider: "mock", metadata: {} },
|
||||
@@ -26,7 +26,7 @@ describe("ProjectAgentLock WellFormed (integration, ADR-0002)", () => {
|
||||
|
||||
it("acquireLock fails when project already locked (exclusivity)", async () => {
|
||||
const project = await prisma.project.create({
|
||||
data: { id: "p-lock-2", name: "Test", workspaceDir: "/tmp/x" },
|
||||
data: { id: "p-lock-2", organizationId: DEFAULT_ORG_ID, name: "Test", workspaceDir: "/tmp/x" },
|
||||
});
|
||||
const run1 = await prisma.agentRun.create({
|
||||
data: { projectId: project.id, entrypoint: "FEISHU", status: "ACTIVE", prompt: "x", model: "m", provider: "mock", metadata: {} },
|
||||
@@ -41,7 +41,7 @@ describe("ProjectAgentLock WellFormed (integration, ADR-0002)", () => {
|
||||
|
||||
it("currentLockRunId throws when a terminal run holds the lock (WellFormed)", async () => {
|
||||
const project = await prisma.project.create({
|
||||
data: { id: "p-lock-3", name: "Test", workspaceDir: "/tmp/x" },
|
||||
data: { id: "p-lock-3", organizationId: DEFAULT_ORG_ID, name: "Test", workspaceDir: "/tmp/x" },
|
||||
});
|
||||
// Create a run that is already COMPLETED (terminal), then manually insert a lock.
|
||||
const run = await prisma.agentRun.create({
|
||||
@@ -57,7 +57,7 @@ describe("ProjectAgentLock WellFormed (integration, ADR-0002)", () => {
|
||||
|
||||
it("releaseLock is idempotent", async () => {
|
||||
const project = await prisma.project.create({
|
||||
data: { id: "p-lock-4", name: "Test", workspaceDir: "/tmp/x" },
|
||||
data: { id: "p-lock-4", organizationId: DEFAULT_ORG_ID, name: "Test", workspaceDir: "/tmp/x" },
|
||||
});
|
||||
const run = await prisma.agentRun.create({
|
||||
data: { projectId: project.id, entrypoint: "FEISHU", status: "ACTIVE", prompt: "x", model: "m", provider: "mock", metadata: {} },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach, afterAll } from "vitest";
|
||||
import { prisma, resetDb } from "./helpers.js";
|
||||
import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js";
|
||||
import { canTriggerRole } from "../../src/permission.js";
|
||||
|
||||
describe("canTriggerRole (integration, per-role gate)", () => {
|
||||
@@ -13,7 +13,7 @@ describe("canTriggerRole (integration, per-role gate)", () => {
|
||||
|
||||
it("allows when role is unconfigured on the project (back-compat: open)", async () => {
|
||||
const project = await prisma.project.create({
|
||||
data: { id: "p-role-1", name: "T", workspaceDir: "/tmp/x" },
|
||||
data: { id: "p-role-1", organizationId: DEFAULT_ORG_ID, name: "T", workspaceDir: "/tmp/x" },
|
||||
});
|
||||
const r = await canTriggerRole(prisma, project.id, "draft", "ou_a");
|
||||
expect(r.allowed).toBe(true);
|
||||
@@ -21,7 +21,7 @@ describe("canTriggerRole (integration, per-role gate)", () => {
|
||||
|
||||
it("allows when principal holds an active grant for the role", async () => {
|
||||
const project = await prisma.project.create({
|
||||
data: { id: "p-role-2", name: "T", workspaceDir: "/tmp/x" },
|
||||
data: { id: "p-role-2", organizationId: DEFAULT_ORG_ID, name: "T", workspaceDir: "/tmp/x" },
|
||||
});
|
||||
await prisma.roleTriggerGrant.create({
|
||||
data: { projectId: project.id, roleId: "review", principalType: "USER", principalId: "ou_a" },
|
||||
@@ -32,7 +32,7 @@ describe("canTriggerRole (integration, per-role gate)", () => {
|
||||
|
||||
it("denies when grants exist for the role but principal has none", async () => {
|
||||
const project = await prisma.project.create({
|
||||
data: { id: "p-role-3", name: "T", workspaceDir: "/tmp/x" },
|
||||
data: { id: "p-role-3", organizationId: DEFAULT_ORG_ID, name: "T", workspaceDir: "/tmp/x" },
|
||||
});
|
||||
// Someone else has the role; ou_b does not.
|
||||
await prisma.roleTriggerGrant.create({
|
||||
@@ -44,7 +44,7 @@ describe("canTriggerRole (integration, per-role gate)", () => {
|
||||
|
||||
it("denies when the principal's grant was revoked", async () => {
|
||||
const project = await prisma.project.create({
|
||||
data: { id: "p-role-4", name: "T", workspaceDir: "/tmp/x" },
|
||||
data: { id: "p-role-4", organizationId: DEFAULT_ORG_ID, name: "T", workspaceDir: "/tmp/x" },
|
||||
});
|
||||
await prisma.roleTriggerGrant.create({
|
||||
data: { projectId: project.id, roleId: "review", principalType: "USER", principalId: "ou_a", revokedAt: new Date() },
|
||||
@@ -54,8 +54,8 @@ describe("canTriggerRole (integration, per-role gate)", () => {
|
||||
});
|
||||
|
||||
it("scopes grants per-project (grant on p1 does not allow on p2)", async () => {
|
||||
const p1 = await prisma.project.create({ data: { id: "p-role-5a", name: "T", workspaceDir: "/tmp/x" } });
|
||||
const p2 = await prisma.project.create({ data: { id: "p-role-5b", name: "T", workspaceDir: "/tmp/x" } });
|
||||
const p1 = await prisma.project.create({ data: { id: "p-role-5a", organizationId: DEFAULT_ORG_ID, name: "T", workspaceDir: "/tmp/x" } });
|
||||
const p2 = await prisma.project.create({ data: { id: "p-role-5b", organizationId: DEFAULT_ORG_ID, name: "T", workspaceDir: "/tmp/x" } });
|
||||
await prisma.roleTriggerGrant.create({
|
||||
data: { projectId: p1.id, roleId: "review", principalType: "USER", principalId: "ou_a" },
|
||||
});
|
||||
|
||||
@@ -166,6 +166,7 @@ function allowAllAuthorizer(): PermissionAuthorizer {
|
||||
action: req.action,
|
||||
resource: req.resource,
|
||||
actor: req.actor,
|
||||
organizationId: "org_test_default",
|
||||
actorUserId: "user-1",
|
||||
principals: [{ type: "USER", id: "ou_user" }],
|
||||
requiredRole: "EDIT",
|
||||
|
||||
@@ -235,6 +235,7 @@ function allowAllAuthorizer(): PermissionAuthorizer {
|
||||
action: req.action,
|
||||
resource: req.resource,
|
||||
actor: req.actor,
|
||||
organizationId: "org_test_default",
|
||||
actorUserId: "user-1",
|
||||
principals: [{ type: "USER", id: "ou_user" }],
|
||||
requiredRole: "EDIT",
|
||||
|
||||
Reference in New Issue
Block a user