[GH-ISSUE #7822] "hasPermission" is not working for Dynamic Access Control in organization #10918

Closed
opened 2026-04-13 07:18:52 -05:00 by GiteaMirror · 4 comments
Owner

Originally created by @devmdfaiz on GitHub (Feb 6, 2026).
Original GitHub issue: https://github.com/better-auth/better-auth/issues/7822

Is this suited for github?

  • Yes, this is suited for github

To Reproduce

I am implementing Dynamic Access Control for organization-level permissions.
While validating permissions using hasPermission, it always returns false — even when the member clearly has the required role/permissions assigned.

I have also added a custom column in the organization_role table to support dynamic permissions.

import { TRPCError } from "@trpc/server";

import { memberHasPermission } from "@/lib/validator/member-has-permission.js";
import { t } from "@/trpc.js";

import { hasOrganizationMiddleware } from "../general/organization.middleware.js";
import { hasSessionMiddleware } from "../general/session.middleware.js";
import { auth } from "@/better-auth/auth.js";
import { fromNodeHeaders } from "better-auth/node";

export const canPerformActionOnChannelMessageFrequencyCapProcedure = (action: "set" | "view" | "update" | "delete") => {
  const canViewOrganizationKycMiddleware = t.middleware(async ({ ctx, next }) => {
    const { organization, session } = ctx;

    // const { allowed, reason } = await memberHasPermission({
    //   organizationId: organization!.id,
    //   userId: session!.user.id,
    //   permissions: {
    //     email_message_cap: [action],
    //     sms_message_cap: [action],
    //     whatsapp_message_cap: [action],
    //     rcs_message_cap: [action],
    //   },
    // });

    const { error: reason, success: allowed } = await auth.api.hasPermission({
      headers: fromNodeHeaders(ctx.req.headers),
      body: {
        organizationId: organization!.id,
        permissions: {
          email_message_cap: [action],
          sms_message_cap: [action],
          whatsapp_message_cap: [action],
          rcs_message_cap: [action],
        }
      }
    })

    console.log({ reason, allowed });

    if (reason) {
      throw new TRPCError({
        code: "INTERNAL_SERVER_ERROR",
        message: reason,
      });
    }

    if (!allowed) {
      throw new TRPCError({
        code: "FORBIDDEN",
        message: "You don't have permission to access this resource.",
      });
    }

    return next();
  });

  return t.procedure.use(hasSessionMiddleware).use(hasOrganizationMiddleware).use(canViewOrganizationKycMiddleware);
};

Current vs. Expected behavior

I expected true but getting false

What version of Better Auth are you using?

1.4.13

System info

Device name	LAPTOP-8870G1A4
Processor	13th Gen Intel(R) Core(TM) i5-13450HX (2.40 GHz)
Installed RAM	16.0 GB (15.7 GB usable)
System type	64-bit operating system, x64-based processor

Which area(s) are affected? (Select all that apply)

Backend

Auth config (if applicable)

export const organizationPlugin = {
  attach: () => organizationPluginBetterAuthInstance({
    organizationCreation: {
      disabled: false,
    },
    ac,
    dynamicAccessControl: {
      enabled: true,
    },
    organizationHooks: {
      beforeCreateOrganization: async ({ organization }) => {
        const priorityOptions: typeof organizationSchema.$inferInsert["metadata"]["priority"][] = ["high", "medium", "low"];

        const rcsProviderOptions: typeof organizationSchema.$inferInsert["metadata"]["rcsProvider"][] = ["jio", "vi"];

        if (!organization.metadata) {
          throw new APIError("BAD_REQUEST", {
            message: "Organization metadata is required",
            code: "MISSING_METADATA",
          });
        }

        if (!organization.metadata.priority) {
          throw new APIError("BAD_REQUEST", {
            message: "Organization priority is required",
            code: "MISSING_METADATA_PRIORITY",
          });
        }

        if (!organization.metadata.rcsProvider) {
          throw new APIError("BAD_REQUEST", {
            message: "Organization rcs provider is required",
            code: "MISSING_METADATA_RCS_PROVIDER",
          });
        }

        if (!priorityOptions.includes(organization.metadata.priority)) {
          throw new APIError("BAD_REQUEST", {
            message: `Invalid priority value. Valid values are: ${priorityOptions.join(", ")}`,
            code: "INVALID_PRIORITY_VALUE",
          });
        }

        if (!rcsProviderOptions.includes(organization.metadata.rcsProvider)) {
          throw new APIError("BAD_REQUEST", {
            message: `Invalid rcs provider value. Valid values are: ${rcsProviderOptions.join(", ")}`,
            code: "INVALID_RCS_PROVIDER_VALUE",
          });
        }

        return {
          data: {
            ...organization,
          },
        };
      },
      afterCreateOrganization: async ({ organization }) => {
        const result = await TryCatch.async(async () => {
          await db.insert(organizationRole).values({
            id: GenerateId.uuidByLength(16),
            organizationId: organization.id,
            role: "owner",
            permission: JSON.stringify(statement),
            createdAt: new Date(),
            updatedAt: new Date(),
          });
        }, {
          enableRetry: true,
        });

        if (!result.success) {
          throw new APIError("INTERNAL_SERVER_ERROR", {
            message: "Role creation failed after organization creation. This might cause issues with the newly created organization. Please technical team.",
          });
        }
      },
    },
    allowUserToCreateOrganization: async (user) => {
      const { role } = user;

      if (role === "manager" || role === "admin") {
        return true;
      }

      return false;
    },
    async sendInvitationEmail(data) {
      // TODO: update link with actual link
      const inviteLink = `https://example.com/accept-invitation/${data.id}`;

      const { email: invitationForEmail, role: invitationForRole, organization: { name: organizationName } } = data;

      const productionResult = await TryCatch.async(async () => {
        await redisClient.xadd(
          "auth:notification",
          "*",
          "email",
          invitationForEmail,
          "role",
          invitationForRole,
          "organizationName",
          organizationName,
          "type",
          "invitation",
          "url",
          inviteLink,
        );
      });

      if (!productionResult.success) {
        throw new APIError("INTERNAL_SERVER_ERROR", {
          message: "Something went wrong",
        });
      }
    },
  }),
};

Additional context

To solve this i created my own validator for now

import { TryCatch } from "@telepie-technology/utils/exception";

import type { statement } from "@/better-auth/permissions/organization-statement.permissions.js";

import db from "../database/db.js";

type PermissionsInput = Partial<{
  [R in keyof typeof statement]: readonly (typeof statement)[R][number][];
}>;

type Params = {
  userId: string;
  organizationId: string;
  permissions: PermissionsInput;
};

export const memberHasPermission = async (params: Params) => {
  const { organizationId, permissions, userId } = params;

  const result = await TryCatch.async(async () => {
    const mRole = await db.query.member.findFirst({
      where: (member, { eq, and }) =>
        and(eq(member.userId, userId), eq(member.organizationId, organizationId)),
      columns: { role: true },
    });

    if (!mRole) {
      return { allowed: false, reason: "Member not found in organization" };
    }

    const oPermissions = await db.query.organizationRole.findFirst({
      where: (orgRole, { eq, and }) => and(eq(orgRole.organizationId, organizationId), eq(orgRole.role, mRole.role)),
      columns: { permission: true },
    });

    if (!oPermissions) {
      return { allowed: false, reason: "Role permissions not found" };
    }

    const rolePermissions = JSON.parse(oPermissions.permission) as typeof statement;

    // Permission check logic
    for (const resource in permissions) {
      const requiredActions
        = (permissions[resource as keyof typeof statement] ?? []) as readonly string[];

      const roleActions
        = (rolePermissions[resource as keyof typeof statement] ?? []) as readonly string[];

      for (const action of requiredActions) {
        if (!(roleActions).includes(action)) {
          return {
            allowed: false,
            reason: `Missing permission: ${resource}:${action}`,
          };
        }
      }
    }

    return { allowed: true };
  });

  if (!result.success) {
    return {
      allowed: false,
      reason: result.error.message,
    };
  }

  return result.output;
};

Originally created by @devmdfaiz on GitHub (Feb 6, 2026). Original GitHub issue: https://github.com/better-auth/better-auth/issues/7822 ### Is this suited for github? - [x] Yes, this is suited for github ### To Reproduce I am implementing Dynamic Access Control for organization-level permissions. While validating permissions using `hasPermission`, it always returns `false` — even when the member clearly has the required role/permissions assigned. I have also added a custom column in the organization_role table to support dynamic permissions. ```ts import { TRPCError } from "@trpc/server"; import { memberHasPermission } from "@/lib/validator/member-has-permission.js"; import { t } from "@/trpc.js"; import { hasOrganizationMiddleware } from "../general/organization.middleware.js"; import { hasSessionMiddleware } from "../general/session.middleware.js"; import { auth } from "@/better-auth/auth.js"; import { fromNodeHeaders } from "better-auth/node"; export const canPerformActionOnChannelMessageFrequencyCapProcedure = (action: "set" | "view" | "update" | "delete") => { const canViewOrganizationKycMiddleware = t.middleware(async ({ ctx, next }) => { const { organization, session } = ctx; // const { allowed, reason } = await memberHasPermission({ // organizationId: organization!.id, // userId: session!.user.id, // permissions: { // email_message_cap: [action], // sms_message_cap: [action], // whatsapp_message_cap: [action], // rcs_message_cap: [action], // }, // }); const { error: reason, success: allowed } = await auth.api.hasPermission({ headers: fromNodeHeaders(ctx.req.headers), body: { organizationId: organization!.id, permissions: { email_message_cap: [action], sms_message_cap: [action], whatsapp_message_cap: [action], rcs_message_cap: [action], } } }) console.log({ reason, allowed }); if (reason) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: reason, }); } if (!allowed) { throw new TRPCError({ code: "FORBIDDEN", message: "You don't have permission to access this resource.", }); } return next(); }); return t.procedure.use(hasSessionMiddleware).use(hasOrganizationMiddleware).use(canViewOrganizationKycMiddleware); }; ``` ### Current vs. Expected behavior I expected `true` but getting `false` ### What version of Better Auth are you using? 1.4.13 ### System info ```bash Device name LAPTOP-8870G1A4 Processor 13th Gen Intel(R) Core(TM) i5-13450HX (2.40 GHz) Installed RAM 16.0 GB (15.7 GB usable) System type 64-bit operating system, x64-based processor ``` ### Which area(s) are affected? (Select all that apply) Backend ### Auth config (if applicable) ```typescript export const organizationPlugin = { attach: () => organizationPluginBetterAuthInstance({ organizationCreation: { disabled: false, }, ac, dynamicAccessControl: { enabled: true, }, organizationHooks: { beforeCreateOrganization: async ({ organization }) => { const priorityOptions: typeof organizationSchema.$inferInsert["metadata"]["priority"][] = ["high", "medium", "low"]; const rcsProviderOptions: typeof organizationSchema.$inferInsert["metadata"]["rcsProvider"][] = ["jio", "vi"]; if (!organization.metadata) { throw new APIError("BAD_REQUEST", { message: "Organization metadata is required", code: "MISSING_METADATA", }); } if (!organization.metadata.priority) { throw new APIError("BAD_REQUEST", { message: "Organization priority is required", code: "MISSING_METADATA_PRIORITY", }); } if (!organization.metadata.rcsProvider) { throw new APIError("BAD_REQUEST", { message: "Organization rcs provider is required", code: "MISSING_METADATA_RCS_PROVIDER", }); } if (!priorityOptions.includes(organization.metadata.priority)) { throw new APIError("BAD_REQUEST", { message: `Invalid priority value. Valid values are: ${priorityOptions.join(", ")}`, code: "INVALID_PRIORITY_VALUE", }); } if (!rcsProviderOptions.includes(organization.metadata.rcsProvider)) { throw new APIError("BAD_REQUEST", { message: `Invalid rcs provider value. Valid values are: ${rcsProviderOptions.join(", ")}`, code: "INVALID_RCS_PROVIDER_VALUE", }); } return { data: { ...organization, }, }; }, afterCreateOrganization: async ({ organization }) => { const result = await TryCatch.async(async () => { await db.insert(organizationRole).values({ id: GenerateId.uuidByLength(16), organizationId: organization.id, role: "owner", permission: JSON.stringify(statement), createdAt: new Date(), updatedAt: new Date(), }); }, { enableRetry: true, }); if (!result.success) { throw new APIError("INTERNAL_SERVER_ERROR", { message: "Role creation failed after organization creation. This might cause issues with the newly created organization. Please technical team.", }); } }, }, allowUserToCreateOrganization: async (user) => { const { role } = user; if (role === "manager" || role === "admin") { return true; } return false; }, async sendInvitationEmail(data) { // TODO: update link with actual link const inviteLink = `https://example.com/accept-invitation/${data.id}`; const { email: invitationForEmail, role: invitationForRole, organization: { name: organizationName } } = data; const productionResult = await TryCatch.async(async () => { await redisClient.xadd( "auth:notification", "*", "email", invitationForEmail, "role", invitationForRole, "organizationName", organizationName, "type", "invitation", "url", inviteLink, ); }); if (!productionResult.success) { throw new APIError("INTERNAL_SERVER_ERROR", { message: "Something went wrong", }); } }, }), }; ``` ### Additional context To solve this i created my own validator for now ```ts import { TryCatch } from "@telepie-technology/utils/exception"; import type { statement } from "@/better-auth/permissions/organization-statement.permissions.js"; import db from "../database/db.js"; type PermissionsInput = Partial<{ [R in keyof typeof statement]: readonly (typeof statement)[R][number][]; }>; type Params = { userId: string; organizationId: string; permissions: PermissionsInput; }; export const memberHasPermission = async (params: Params) => { const { organizationId, permissions, userId } = params; const result = await TryCatch.async(async () => { const mRole = await db.query.member.findFirst({ where: (member, { eq, and }) => and(eq(member.userId, userId), eq(member.organizationId, organizationId)), columns: { role: true }, }); if (!mRole) { return { allowed: false, reason: "Member not found in organization" }; } const oPermissions = await db.query.organizationRole.findFirst({ where: (orgRole, { eq, and }) => and(eq(orgRole.organizationId, organizationId), eq(orgRole.role, mRole.role)), columns: { permission: true }, }); if (!oPermissions) { return { allowed: false, reason: "Role permissions not found" }; } const rolePermissions = JSON.parse(oPermissions.permission) as typeof statement; // Permission check logic for (const resource in permissions) { const requiredActions = (permissions[resource as keyof typeof statement] ?? []) as readonly string[]; const roleActions = (rolePermissions[resource as keyof typeof statement] ?? []) as readonly string[]; for (const action of requiredActions) { if (!(roleActions).includes(action)) { return { allowed: false, reason: `Missing permission: ${resource}:${action}`, }; } } } return { allowed: true }; }); if (!result.success) { return { allowed: false, reason: result.error.message, }; } return result.output; }; ```
GiteaMirror added the locked label 2026-04-13 07:18:52 -05:00
Author
Owner

@better-auth-agent[bot] commented on GitHub (Feb 6, 2026):

It looks like you might be encountering an issue where hasPermission is returning false even when the member has the appropriate permissions. This could be related to a couple of things:

  1. Permission Aggregation: Previously, hasPermission required that a single role contained all requested permissions. However, it's now expected to aggregate permissions from all roles assigned to a user. If you have multiple roles, make sure that the combined roles grant all the permissions needed [1].

  2. Dynamic Access Control: Ensure that dynamic access control is correctly set up. You mentioned you had a custom column in the organization_role table. Double-check that the permissions structure in your request matches what is configured in Better Auth [2].

  3. Custom Role Configuration: If you have custom roles defined, ensure that they include all necessary default roles because missing or typoed role definitions can lead to silent failures where hasPermission returns false without clear feedback [3].

  4. Testing with Logs: Consider adding logging statements just before the hasPermission call to validate that the correct parameters are being used, and ensure the user does indeed have the expected roles before making the API call.

If you've checked all of this and it still doesn't work, you might want to tag @bekacru or @ping__ for further assistance, as they can provide help specific to your implementation.

What errors or unexpected behaviors do you see in your logs when the permissions fail?

If you need more help, tag @better-auth-agent in a comment so I can respond.

DiagramDiscordGitHub

Diagram Join Star

<!-- gh-comment-id:3859823112 --> @better-auth-agent[bot] commented on GitHub (Feb 6, 2026): It looks like you might be encountering an issue where `hasPermission` is returning `false` even when the member has the appropriate permissions. This could be related to a couple of things: 1. **Permission Aggregation**: Previously, `hasPermission` required that a single role contained all requested permissions. However, it's now expected to aggregate permissions from all roles assigned to a user. If you have multiple roles, make sure that the combined roles grant all the permissions needed [[1]](https://www.better-auth.com/docs/plugins/organization). 2. **Dynamic Access Control**: Ensure that dynamic access control is correctly set up. You mentioned you had a custom column in the `organization_role` table. Double-check that the permissions structure in your request matches what is configured in Better Auth [[2]](https://github.com/better-auth/better-auth/issues/3011). 3. **Custom Role Configuration**: If you have custom roles defined, ensure that they include all necessary default roles because missing or typoed role definitions can lead to silent failures where `hasPermission` returns false without clear feedback [[3]](https://github.com/better-auth/better-auth/issues/6773). 4. **Testing with Logs**: Consider adding logging statements just before the `hasPermission` call to validate that the correct parameters are being used, and ensure the user does indeed have the expected roles before making the API call. If you've checked all of this and it still doesn't work, you might want to tag @bekacru or @ping__ for further assistance, as they can provide help specific to your implementation. What errors or unexpected behaviors do you see in your logs when the permissions fail? _If you need more help, tag @better-auth-agent in a comment so I can respond._ <!-- bot:webhook reply v1 --> [Diagram](https://repodiagrams.s3.eu-north-1.amazonaws.com/better-auth_ultra_detailed_interactive.html) • [Discord](https://discord.gg/better-auth) • [GitHub](https://github.com/better-auth/better-auth) [![Diagram](https://img.shields.io/badge/Diagram-2b3137?style=flat-square)](https://repodiagrams.s3.eu-north-1.amazonaws.com/better-auth_ultra_detailed_interactive.html) [![Join](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&style=flat-square)](https://discord.gg/better-auth) [![Star](https://img.shields.io/badge/star-181717?logo=github&logoColor=white&style=flat-square)](https://github.com/better-auth/better-auth)
Author
Owner

@dagimkassagebrie commented on GitHub (Feb 7, 2026):

Checking at the code, It looks like in src/plugins/organization/has-permission.ts at line 50, if (role in acRoles) continue, skips checking DB stored permissions if the role matches a predefined role name(owner, admin, member). So when dynamicAccessControl is enabled, custom permissions stored in organizationRole are never applied to those built-in roles, @better-auth-agent.

<!-- gh-comment-id:3863729942 --> @dagimkassagebrie commented on GitHub (Feb 7, 2026): Checking at the code, It looks like in src/plugins/organization/has-permission.ts at line 50, if (role in acRoles) continue, skips checking DB stored permissions if the role matches a predefined role name(owner, admin, member). So when dynamicAccessControl is enabled, custom permissions stored in organizationRole are never applied to those built-in roles, @better-auth-agent.
Author
Owner

@better-auth-agent[bot] commented on GitHub (Feb 7, 2026):

It looks like you've encountered an issue where the hasPermission function is not correctly applying dynamic permissions when roles match predefined ones (like owner or admin).

This behavior stems from the fact that the current implementation may skip checking dynamically assigned permissions in cases where a role matches built-in roles. This is indeed a known limitation when using dynamic access control, especially when custom permissions are essential.

As a potential next step, you may wish to enhance the logic in your permission checks by ensuring that dynamic permissions are aggregated and considered even for predefined roles. According to the modifications planned in better-auth's future updates, there is ongoing work to aggregate permissions from all roles, which would address this exact concern [2] [4].

In the meantime, you might want to continue using your custom validator for now while this enhancement is addressed in better-auth. If you're able, including logging or other error handling in your current validation could help diagnose issues further.

Would you like to explore more about adjusting the hasPermission function's behavior, or do you have any other questions about leveraging Better Auth?

DiagramDiscordGitHub

Diagram Join Star

<!-- gh-comment-id:3863730247 --> @better-auth-agent[bot] commented on GitHub (Feb 7, 2026): It looks like you've encountered an issue where the `hasPermission` function is not correctly applying dynamic permissions when roles match predefined ones (like owner or admin). This behavior stems from the fact that the current implementation may skip checking dynamically assigned permissions in cases where a role matches built-in roles. This is indeed a known limitation when using dynamic access control, especially when custom permissions are essential. As a potential next step, you may wish to enhance the logic in your permission checks by ensuring that dynamic permissions are aggregated and considered even for predefined roles. According to the modifications planned in better-auth's future updates, there is ongoing work to aggregate permissions from all roles, which would address this exact concern [[2]](https://github.com/better-auth/better-auth/issues/3011) [[4]](https://github.com/better-auth/better-auth/pull/3074). In the meantime, you might want to continue using your custom validator for now while this enhancement is addressed in better-auth. If you're able, including logging or other error handling in your current validation could help diagnose issues further. Would you like to explore more about adjusting the `hasPermission` function's behavior, or do you have any other questions about leveraging Better Auth? <!-- bot:webhook reply v1 --> [Diagram](https://repodiagrams.s3.eu-north-1.amazonaws.com/better-auth_ultra_detailed_interactive.html) • [Discord](https://discord.gg/better-auth) • [GitHub](https://github.com/better-auth/better-auth) [![Diagram](https://img.shields.io/badge/Diagram-2b3137?style=flat-square)](https://repodiagrams.s3.eu-north-1.amazonaws.com/better-auth_ultra_detailed_interactive.html) [![Join](https://img.shields.io/badge/join-5865F2?logo=discord&logoColor=white&style=flat-square)](https://discord.gg/better-auth) [![Star](https://img.shields.io/badge/star-181717?logo=github&logoColor=white&style=flat-square)](https://github.com/better-auth/better-auth)
Author
Owner

@bytaesu commented on GitHub (Feb 8, 2026):

I'm looking into this 🧐

<!-- gh-comment-id:3867739728 --> @bytaesu commented on GitHub (Feb 8, 2026): I'm looking into this 🧐
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: github-starred/better-auth#10918