Club Memberships & Admin Roles
The access control system manages club memberships with role-based permissions at both club and tenant levels.
Architecture
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ JWT Token │────▶│ Permission │────▶│ Membership │
│ (userId) │ │ Guard │ │ Lookup │
└─────────────────┘ └──────────────────┘ └─────────────────┘
│ │
▼ ▼
┌──────────────────┐ ┌─────────────────┐
│ Role-Permission │ │ Cache (60s) │
│ Mapping │ │ │
└──────────────────┘ └─────────────────┘
│
▼
┌──────────────────┐
│ Allow/Deny │
└──────────────────┘
Sources:
apps/teetime/teetime-backend/src/admin/club-memberships.service.tslibs/access-control/src/lib/club-permission.guard.ts
Club Roles
| Role | Description | Use Case |
|---|---|---|
CLUB_ADMIN | Full club administration | Club managers, owners |
PRO_SHOP_STAFF | Booking and payment operations | Pro shop staff |
COACH | Lesson management | Teaching professionals |
PLAYER | Basic member access | Club members |
Tenant Roles
Higher-level roles for multi-club operations:
| Role | Capabilities |
|---|---|
OWNER | Full tenant access, billing, feature toggles |
ADMIN | Ops manager: tee-sheet, pricing, calendars, reports |
PROSHOP | Booking/modify/cancel, payments, check-in |
STARTER | Check-in, view tee-sheet, no-shows |
FINANCE | Payouts, settlements, refunds, exports |
REGISTERED | Self-booking, wallet, receipts |
Permissions
Club-Level Permissions
| Permission | CLUB_ADMIN | PRO_SHOP_STAFF | COACH | PLAYER |
|---|---|---|---|---|
TEE_SHEET_VIEW | ✅ | ✅ | ✅ | ✅ |
TEE_SHEET_EDIT | ✅ | ✅ | ❌ | ❌ |
REFUND_PROCESS | ✅ | ✅ | ❌ | ❌ |
LESSON_MANAGE | ✅ | ❌ | ✅ | ❌ |
Tenant-Level Permissions
| Category | Permissions |
|---|---|
| Tenant/Ops | tenant.update, feature.toggle, staff.invite, env.config |
| Tee Sheet | teesheet.view, teesheet.create, teesheet.update, teesheet.lock |
| Booking | booking.create, booking.modify, booking.cancel, booking.checkin |
| Pricing | rates.view, rates.update, promo.manage, policy.manage |
| Members | member.view, member.update, member.notes |
| Payments | payment.charge, payment.refund, payout.view, export.journal |
| Reports | report.view, report.download |
Membership Model
interface Membership {
id: string;
userId: string;
clubId: string;
role: ClubRole;
scope?: JsonValue; // Fine-grained access
providerRole?: string; // External system role
active: boolean; // Enable/disable
}
Constraints:
- Unique on
[userId, clubId, role]— allows multiple roles per user per club - Indexed on
[clubId, role]and[userId, active]for performance
API Endpoints
List Memberships
GET /admin/clubs/:clubId/memberships
// Response
[
{
"id": "mem-123",
"userId": "user-456",
"role": "CLUB_ADMIN",
"active": true
},
{
"id": "mem-789",
"userId": "user-abc",
"role": "PRO_SHOP_STAFF",
"active": true
}
]
Guards: JwtAuthGuard, AudienceGuard(teetime-admin), ClubPermissionGuard(TEE_SHEET_VIEW, CLUB_ADMIN)
Create/Update Membership
POST /admin/clubs/:clubId/memberships
// Request
{
"userId": "user-456",
"role": "PRO_SHOP_STAFF",
"active": true
}
// Response
{
"id": "mem-new",
"userId": "user-456",
"role": "PRO_SHOP_STAFF",
"active": true
}
Guards: JwtAuthGuard, AudienceGuard(teetime-admin), ClubPermissionGuard(TEE_SHEET_EDIT, CLUB_ADMIN)
Permission Guard
@Injectable()
export class ClubPermissionGuard implements CanActivate {
constructor(
private readonly required: Permission,
private readonly options: ClubPermissionGuardOptions
) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
// 1. Extract userId from JWT
const userId = extractUserId(request);
// 2. Resolve clubId (from params, body, or courseId lookup)
const clubId = await resolveClubId(request, options);
// 3. Get user's roles (cached 60s)
const roles = await getUserClubRoles(userId, clubId);
// 4. Check role requirement if specified
if (options.requireRoles && !roles.some(r => options.requireRoles.includes(r))) {
return false;
}
// 5. Check permission for roles
return roles.some(role => hasPermission(role, required));
}
}
Usage:
@UseGuards(ClubPermissionGuard.with(Permission.TEE_SHEET_EDIT, { requireRoles: ['CLUB_ADMIN'] }))
@Post()
async createMembership() { ... }
Invitation System
Invitation Model
interface Invitation {
id: string;
token: string; // Unique invite token
userId: string; // Target user
tenantId: string;
requiredScopes: string[]; // Scopes to grant
expiresAt: Date;
usedAt?: Date; // When accepted
declinedAt?: Date; // When rejected
}
Invitation Lifecycle
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ CREATE │────▶│ PENDING │────▶│ ACCEPTED │
│ (send link) │ │ (awaiting) │ │ (usedAt) │
└─────────────┘ └─────────────┘ └─────────────┘
│
▼
┌─────────────┐
│ DECLINED │
│ (declinedAt)│
└─────────────┘
Invitation Methods
// Create invitation with expiration
createInvitation(userId, tenantId, scopes, expiresAt): Promise<Invitation>
// Validate token and check expiration
findInvitationByToken(token): Promise<Invitation | null>
isExpired(invitation): boolean
// Accept invitation - grants membership
markInvitationAsUsed(id): Promise<void>
// Reject or admin cancel
markInvitationAsDeclined(id): Promise<void>
markInvitationAsCanceled(id): Promise<void>
Role Caching
User roles are cached for performance:
// Get roles with 60-second TTL
const roles = await getUserClubRoles(userId, clubId);
// Invalidate on membership change
invalidateUserClubRolesCache(userId, clubId);
Cache Key: club:roles:${userId}:${clubId}
Membership Lifecycle
1. Invite User
// Admin creates invitation
const invite = await invitationsService.createInvitation(
userId,
tenantId,
['booking.create', 'teesheet.view'],
addDays(new Date(), 7) // 7-day expiration
);
// Send email with invite link
await sendInviteEmail(user.email, invite.token);
2. Accept Invitation
// User clicks invite link
const invite = await invitationsService.findInvitationByToken(token);
if (invitationsService.isExpired(invite)) {
throw new BadRequestException('Invitation expired');
}
// Mark as used and create membership
await invitationsService.markInvitationAsUsed(invite.id);
await membershipsService.upsert(clubId, userId, role, true);
3. Revoke Access
// Disable membership (soft revoke)
await membershipsService.upsert(clubId, userId, role, false);
// Cache automatically invalidated
4. Re-enable Access
// Re-activate membership
await membershipsService.upsert(clubId, userId, role, true);
Club ID Resolution
The permission guard resolves club ID from multiple sources:
- Request Params:
req.params.clubId - Request Body:
req.body.clubId - Query String:
req.query.clubId - Course Lookup: Derive from
courseId→ club relationship
const options: ClubPermissionGuardOptions = {
clubIdParam: 'clubId', // Default param name
requireRoles: ['CLUB_ADMIN'] // Optional role requirement
};
Security Features
| Feature | Description |
|---|---|
| JWT Authentication | All endpoints require valid JWT |
| Audience Validation | Must have teetime-admin scope |
| Role-Based Access | Specific roles required for operations |
| Permission Mapping | Granular permissions per role |
| Cache Invalidation | Immediate effect on role changes |
| Audit Logging | All invitation state changes logged |
| Soft Delete | Invitations support soft delete |
| Expiration | Invitations auto-expire |
Data Model (Prisma)
model Membership {
id String @id @default(uuid()) @db.Uuid
userId String
clubId String @db.Uuid
role ClubRole
scope Json?
providerRole String?
active Boolean @default(true)
club Club @relation(fields: [clubId], references: [id])
@@unique([userId, clubId, role])
@@index([clubId, role])
@@index([userId, active])
}
enum ClubRole {
CLUB_ADMIN
PRO_SHOP_STAFF
COACH
PLAYER
}
Troubleshooting
Permission Denied
- Verify user has active membership for club
- Check role has required permission
- Verify cache is not stale (wait 60s or invalidate)
- Check JWT has correct audience
Invitation Not Working
- Check invitation not expired
- Verify invitation not already used/declined
- Ensure token is correctly URL-encoded
- Check user ID matches invitation target
Role Changes Not Taking Effect
- Cache invalidates automatically on upsert
- If using external cache, verify invalidation
- Wait up to 60 seconds for TTL expiry