Skip to content

Implement CASL Authorization management with recruitment-session-specific roles - #47

Open
cristiansap wants to merge 2 commits into
devfrom
cristian/casl-authorization
Open

Implement CASL Authorization management with recruitment-session-specific roles#47
cristiansap wants to merge 2 commits into
devfrom
cristian/casl-authorization

Conversation

@cristiansap

@cristiansap cristiansap commented May 6, 2026

Copy link
Copy Markdown
Contributor

This PR introduces recruitment-session-specific roles by adding the required database model, resolving the current user's role per recruitment session, and replacing simple role checks with CASL-based authorization across pages, sidebar links, server actions, and API routes.
Moreover, it refactors the "Users" domain model to "Members" for semantic clarity, and improves user experience when switching recruitment sessions.

Here is the list of implemented changes:

Authorization & Security

  1. Implemented session-scoped authorization so permissions are evaluated per recruitment session, not globally.
  2. Added abilityForUserInSession() server helper to resolve CASL abilities based on the session role.
  3. Integrated requirePageAccess() guard on all protected dashboard pages (Members, Candidates, Availability).
  4. Protected all server actions (applicants, interviews, users, recruitment sessions) with session-scoped ability checks.
  5. Unauthorized page access returns HTTP 403 (Forbidden); 404 (Not Found) behavior for non-existent pages is preserved.

Role & Naming Refactor

  1. Renamed domain role UserMember across types, services, database, and seed logic.
  2. Removed God role from code logic (kept commented in enum for future use).

Routes & UI

  1. Migrated endpoint from /users/members to align with the new Member role naming.
  2. Added role-aware Overview subcomponents (GuestOverview, MemberOverview, ClerkOverview, AdminOverview) so the dashboard root renders different content based on the session role.
  3. Updated sidebar navigation link to point to /members route.
  4. Fixed recruitment session switcher to redirect to the dashboard overview on session switch, preventing 403 errors from permission mismatches and enhancing user-friendliness.

Database & Infrastructure

  1. Added users_to_recruiting_sessions table to store recruitment-session-scoped roles per user.
  2. Updated database seed with CLI flags (--session-role, --session-id) to assign test roles.

Copilot AI review requested due to automatic review settings May 6, 2026 13:22

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces recruitment-session-scoped authorization using CASL, so access is determined by the user’s role within a specific recruitment session (via a new join table), and it renames the “Users” domain to “Members” across the dashboard and authorization subjects.

Changes:

  • Added users_to_recruiting_sessions and role resolution (findUserRoleForSession) to compute permissions per recruitment session.
  • Replaced simple role checks with CASL abilities for page access, sidebar link visibility, and multiple server actions.
  • Renamed “Users” → “Members” in routing/UI and improved the recruitment session switcher redirect behavior.

Reviewed changes

Copilot reviewed 36 out of 36 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/middleware.ts Redirect unauthenticated users to /signin for / and /dashboard/*.
src/lib/services/recruitmentSessions.ts Adds session-scoped role lookup (findUserRoleForSession).
src/lib/server/authTypes.ts Renames User role to Member and comments out God.
src/lib/helpers/pageAuthorization.ts Introduces requirePageAccess() guard based on CASL ability.
src/lib/auth.ts Updates Better Auth React client base path to /api/auth.
src/lib/actions/users.ts Protects member-related action with session-scoped ability check.
src/lib/actions/recruitmentSessions.ts Adds ability check before creating recruitment sessions.
src/lib/actions/interviews.ts Adds session-scoped ability check via applicant’s session.
src/lib/actions/availability.ts Adds session-scoped ability check for availability submission.
src/lib/actions/applicants.ts Adds session-scoped ability checks for applicant stage transitions.
src/lib/abilities/user.ts Defines CASL actions/subjects and role-based permissions.
src/lib/abilities/server.ts Adds server helpers to build abilities from session-scoped role.
src/lib/abilities/index.ts Simplifies exports after removing old A<> helper type.
src/db/types.ts Adds types for usersToRecruitingSessions.
src/db/seed.ts Extends seed script with session-role assignment flags and logic.
src/db/schema.ts Adds users_to_recruiting_sessions table and relations.
src/components/ability/AbilityContext.tsx Types AbilityContext as AppAbility.
src/app/page.tsx Redirects to /dashboard if authenticated, otherwise /signin.
src/app/dashboard/layout.tsx Simplifies layout props typing and keeps auth gate.
src/app/dashboard/[rid]/users/page.tsx Removes old /users page under session dashboard.
src/app/dashboard/[rid]/Sidebar.tsx Updates link gating to CASL Can with subjects; adds /members link.
src/app/dashboard/[rid]/Sidebar.data.tsx Refactors sidebar links to map to CASL subjects.
src/app/dashboard/[rid]/RecruitmentSwitcher.tsx Switches sessions by routing to /dashboard/${rid} overview.
src/app/dashboard/[rid]/page.tsx Renders role-specific overview component per session role.
src/app/dashboard/[rid]/members/page.tsx Adds members page guarded via requirePageAccess.
src/app/dashboard/[rid]/members/MembersTable.tsx Updates table to work with members page and action signature (passes rid).
src/app/dashboard/[rid]/MemberOverview.tsx Adds member-specific overview placeholder component.
src/app/dashboard/[rid]/GuestOverview.tsx Adds guest-specific overview placeholder component.
src/app/dashboard/[rid]/ClerkOverview.tsx Adds clerk-specific overview placeholder component.
src/app/dashboard/[rid]/AdminOverview.tsx Adds admin-specific overview placeholder component.
src/app/dashboard/[rid]/candidates/page.tsx Guards candidates list page with requirePageAccess.
src/app/dashboard/[rid]/candidates/[id]/page.tsx Guards candidate details page with requirePageAccess.
src/app/dashboard/[rid]/availability/page.tsx Guards availability overview page with requirePageAccess.
src/app/dashboard/[rid]/me/availability/page.tsx Copy update (English text) on availability page.
src/app/dashboard/[rid]/layout.tsx Resolves session role for the AbilityProvider and sidebar display.
docs/README.md Updates formatting and documents new seed flags for session roles.
Comments suppressed due to low confidence (2)

src/db/seed.ts:123

  • When running the seed script only to assign roles (i.e. shouldSeedData is false), it still mutates existing recruitment sessions by clamping end_date to start_date + 7 days. This is a surprising side effect for a role-assignment run. Consider guarding the whole "update sessions" and "generate timeslots" sections behind shouldSeedData as well.
  // Update recruitment sessions to ensure start_date and end_date are at most 1 week apart
  const recruitingSessions = await db.select().from(schema.recruitingSession);

  for (const session of recruitingSessions) {
    const startDate = new Date(session.start_date);
    const maxEndDate = new Date(startDate);
    maxEndDate.setDate(maxEndDate.getDate() + 7);

    const currentEndDate = new Date(session.end_date);

    // If end_date is more than 1 week after start_date, update it
    if (currentEndDate > maxEndDate) {
      await db
        .update(schema.recruitingSession)
        .set({ end_date: maxEndDate })
        .where(eq(schema.recruitingSession.id, session.id));

      session.end_date = maxEndDate;
    }
  }

src/app/dashboard/[rid]/members/MembersTable.tsx:81

  • w-50 is not a default Tailwind width utility (defaults include w-48, w-52, etc.). If the project isn't extending the spacing scale, this class will be ignored and the column width won't apply. Use a valid utility (e.g. w-52) or an arbitrary value (w-[50px] / w-[12.5rem]) instead.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/lib/auth.ts
Comment thread src/lib/actions/availability.ts Outdated
Comment thread src/db/seed.ts Outdated
Comment thread src/db/seed.ts

@pasc4le pasc4le left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good PR! Thank you. Sorry for submitting so many comments, it's just a big PR. Pretty good implementation though, I love the new approach that you've taken, much cleaner that what I did before.

Comment thread docs/README.md
export default async function AvailabilityOverviewPage({
params,
}: PageProps<'/dashboard/[rid]/availability'>) {
const session = await auth.api.getSession({

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe it would be better to move the page checks to the middleware? Or dashboard layout?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd not move requirePageAccess to the middleware or layout because it queries the DB with both the userId and the recruitingSessionId (rid) to get the role for that specific session (and it's easy to get the rid from this page). So I'd leave it there.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The recruitmentSessionId is also retrievable from the dashboard/[rid]/layout.tsx.

@cristiansap cristiansap May 19, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

True, but requirePageAccess also takes a page-specific subject (e.g. 'AvailabilityOverviewPage') as argument. However, the layout doesn't know which page it's currently rendering, so it can't know which subject to pass to requirePageAccess. Moving it there would mean either calling it for every possible subject (which makes no sense) or refactoring the permission granularity entirely.

Comment thread src/app/dashboard/[rid]/members/MembersTable.tsx
Comment thread src/app/dashboard/[rid]/RecruitmentSwitcher.tsx
Comment thread src/app/dashboard/[rid]/Sidebar.tsx
Comment thread src/lib/actions/applicants.ts Outdated
Comment thread src/lib/actions/availability.ts Outdated
Comment thread src/db/schema.ts
})
);

export const usersToRecruitingSessions = pgTable(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should also remove the old columns from the 'user' table in auth. It is configured by better-auth at lib/server/auth.ts, if you want I can take this one later on as a fix.

Comment thread src/lib/auth.ts
Comment thread src/middleware.ts Outdated
@cristiansap
cristiansap force-pushed the cristian/casl-authorization branch from 4e48cbf to 18a884b Compare May 19, 2026 21:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants