# @taukala/xs-ctrl

A flexible and powerful access control system for JavaScript applications, designed to handle complex authorization patterns including role-based, resource-based, and multi-tenant access control.

## Features

- 🛡️ Comprehensive permission validation
  - Static role-based validation
  - Dynamic resource-based validation
  - Mixed validation combining both approaches
- 🏢 Built for multi-tenant applications
  - Business-level access control
  - Department-level permissions
  - Cross-business user management
- 🔄 Fluent API for creating access rules
- 🎯 Support for complex authorization patterns
- 🔌 Easy integration with any authentication system
- 🎨 Customizable unauthorized handling
- 🚀 Framework agnostic
- 💡 Simple and intuitive API

## Installation

```bash
npm install @taukala/xs-ctrl
```

## Basic Concepts

### Static Rules
Static rules validate against user claims directly, such as roles or permissions.

```javascript
const adminRule = createAccessRule()
  .addCondition('role', 'admin')
  .build();

const managerRule = createAccessRule()
  .addCondition('role', 'manager')
  .addCondition('department', 'IT', 'HR')
  .build();
```

### Dynamic Rules
Dynamic rules validate against resources, perfect for multi-tenant scenarios.

```javascript
const businessOwnerRule = createAccessRule()
  .addCondition('role', 'business')
  .addDynamicCondition(({ claims, resources }) => {
    const businessIds = claims.businessOwner || [];
    return businessIds.includes(resources.business?.id);
  })
  .build();
```

### Mixed Rules
Combine static and dynamic validation for complex scenarios.

```javascript
const businessManagerRule = createAccessRule()
  .addCondition('role', 'business')
  .addCondition('status', 'active')
  .addDynamicCondition(({ claims, resources }) => {
    const businessIds = claims.businessManager || [];
    return businessIds.includes(resources.business?.id);
  })
  .build();
```

## API Reference

### `createAccessRule()`

Creates a builder for constructing access rules with a fluent API.

```javascript
// Static validation
const rule = createAccessRule()
  .addCondition('role', 'business')
  .build();

// Dynamic validation
const rule = createAccessRule()
  .addDynamicCondition(({ claims, resources }) => {
    const businessIds = claims.businessOwner || [];
    return businessIds.includes(resources.business?.id);
  })
  .build();

// Mixed validation
const rule = createAccessRule()
  .addCondition('role', 'business')
  .addDynamicCondition(({ claims, resources }) => {
    const businessIds = claims.businessOwner || [];
    return businessIds.includes(resources.business?.id);
  })
  .addDynamicCondition(({ claims, resources }) => {
    return resources.business?.status === 'active';
  })
  .build();
```

### `createPermissionValidator(options)`

Creates a permission validator with custom handlers.

```javascript
const validatePermission = createPermissionValidator({
  // Get current session
  getSession: async () => {
    return await auth();
  },

  // Get user claims
  getClaims: async (session) => {
    return {
      role: ['business'],
      businessOwner: ['business-1', 'business-2'],
      businessOperator: ['business-3'],
      departmentManager: ['dept-1', 'dept-2']
    };
  },

  onUnauthenticated: () => redirect('/login'),
  onUnauthorized: () => redirect('/')
});
```

### `validateClaim(accessRules, userClaims, context?)`

Core validation function that checks user claims against access rules using OR/AND logic.

```javascript
// Empty rules - no restrictions
await validateClaim([], userClaims); // Returns true

// Static conditions only
await validateClaim([
  {
    conditions: [
      ['role', ['admin']],
      ['status', ['active']]
    ]
  }
], userClaims);

// Dynamic conditions only
await validateClaim([
  {
    dynamicValidators: [
      ({ resources }) => resources.business?.ownerId === resources.user?.id
    ]
  }
], userClaims, { resources });

// Mixed conditions
await validateClaim([
  {
    conditions: [
      ['role', ['business']],
      ['status', ['active']]
    ],
    dynamicValidators: [
      ({ resources }) => resources.business?.ownerId === resources.user?.id
    ]
  }
], userClaims, { resources });

// Multiple rule groups (OR logic)
await validateClaim([
  {
    // Group 1: Admin role
    conditions: [['role', ['admin']]]
  },
  {
    // Group 2: Business owner
    conditions: [['role', ['business']]],
    dynamicValidators: [
      ({ resources }) => resources.business?.ownerId === resources.user?.id
    ]
  }
], userClaims, { resources });
```

#### Parameters

- `accessRules` (Array): Array of rule groups. Each group can contain:
  - `conditions`: Array of static claim conditions `[claimKey, validValues[]]`
  - `dynamicValidators`: Array of functions that return boolean
- `userClaims` (Object): User's claims object
- `context` (Object, optional): Context passed to dynamic validators

#### Validation Logic

1. Empty rules array means no restrictions (returns true)
2. Rule groups are combined with OR logic (user needs to match any group)
3. Conditions within a group use AND logic (user needs to match all conditions)
4. Each group can have:
   - Only static conditions
   - Only dynamic conditions
   - Both static and dynamic conditions

#### Returns

- Returns `Promise<boolean>`, Returns true if validation passes
- Throws error if validation fails

## Usage Examples

### Business Owner Access

```javascript
// Define rules
const businessRules = {
  owner: createAccessRule()
    .addCondition('role', 'business')
    .addDynamicCondition(({ claims, resources }) => {
      const businessIds = claims.businessOwner || [];
      return businessIds.includes(resources.business?.id);
    })
    .build(),

  operator: createAccessRule()
    .addCondition('role', 'business')
    .addDynamicCondition(({ claims, resources }) => {
      const businessIds = claims.businessOperator || [];
      return businessIds.includes(resources.business?.id);
    })
    .build()
};

// Use in route handler
async function updateBusiness(businessId, data) {
  const business = await prisma.business.findUnique({
    where: { id: businessId }
  });

  await validatePermission(
    [businessRules.owner], 
    { resources: { business } }
  );

  // Proceed with update
}
```

### Department-Level Access

```javascript
const departmentRules = {
  manager: createAccessRule()
    .addCondition('role', 'business')
    .addDynamicCondition(({ claims, resources }) => {
      const businessIds = claims.businessOwner || [];
      return businessIds.includes(resources.business?.id);
    })
    .addDynamicCondition(({ claims, resources }) => {
      const departmentIds = claims.departmentManager || [];
      return departmentIds.includes(resources.department?.id);
    })
    .build()
};

async function updateDepartment(businessId, departmentId, data) {
  const [business, department] = await Promise.all([
    prisma.business.findUnique({ where: { id: businessId } }),
    prisma.department.findUnique({ where: { id: departmentId } })
  ]);

  await validatePermission(
    [departmentRules.manager], 
    { 
      resources: { 
        business,
        department 
      } 
    }
  );

  // Proceed with update
}
```

### Multi-Business User Access

```javascript
const multiBusinessRules = {
  anyBusiness: createAccessRule()
    .addCondition('role', 'business')
    .addDynamicCondition(({ claims, resources }) => {
      const ownerIds = claims.businessOwner || [];
      const operatorIds = claims.businessOperator || [];
      const allowedIds = [...ownerIds, ...operatorIds];
      return allowedIds.includes(resources.business?.id);
    })
    .build()
};

// Dashboard page showing all accessible businesses
async function BusinessDashboard() {
  const { claims } = await validatePermission([
    multiBusinessRules.anyBusiness
  ]);

  const ownerBusinesses = await prisma.business.findMany({
    where: { 
      id: { in: claims.businessOwner }
    }
  });

  const operatorBusinesses = await prisma.business.findMany({
    where: { 
      id: { in: claims.businessOperator }
    }
  });

  return (
    <div>
      <h2>Your Businesses</h2>
      {/* Render businesses */}
    </div>
  );
}
```

## Next.js Integration Guide

### Setup Permission Validator

```javascript
// lib/permissions.js
import { createPermissionValidator, createAccessRule } from '@taukala/xs-ctrl';
import { redirect } from 'next/navigation';
import { auth } from '@/auth';

export const validatePermission = createPermissionValidator({
  getSession: auth,
  getClaims: async (session) => {
    if (!session?.user?.id) return {};

    const response = await fetch(
      `${process.env.NEXT_PUBLIC_API_URL}/users/${session.user.id}/claims`,
      { next: { revalidate: 60 } }
    );

    return response.json();
  },
  onUnauthenticated: () => redirect('/auth/signin'),
  onUnauthorized: () => redirect('/dashboard')
});

// Define business-related rules
export const businessRules = {
  owner: createAccessRule()
    .addCondition('role', 'business')
    .addDynamicCondition(({ claims, resources }) => {
      const businessIds = claims.businessOwner || [];
      return businessIds.includes(resources.business?.id);
    })
    .build(),

  operator: createAccessRule()
    .addCondition('role', 'business')
    .addDynamicCondition(({ claims, resources }) => {
      const businessIds = claims.businessOperator || [];
      return businessIds.includes(resources.business?.id);
    })
    .build(),

  departmentManager: createAccessRule()
    .addCondition('role', 'business')
    .addDynamicCondition(({ claims, resources }) => {
      const departmentIds = claims.departmentManager || [];
      return departmentIds.includes(resources.department?.id);
    })
    .build()
};
```

### Protected Routes

```javascript
// app/business/[businessId]/page.js
import { validatePermission, businessRules } from '@/lib/permissions';

async function getBusinessResource(businessId) {
  return await prisma.business.findUnique({
    where: { id: businessId }
  });
}

export default async function BusinessPage({ params }) {
  const business = await getBusinessResource(params.businessId);
  
  const { session, claims } = await validatePermission(
    [businessRules.owner, businessRules.operator],
    { resources: { business } }
  );

  const isOwner = claims.businessOwner?.includes(business.id);

  return (
    <div>
      <h1>{business.name}</h1>
      {isOwner && <EditBusinessButton />}
      {/* Other business content */}
    </div>
  );
}
```

### Server Actions

```javascript
// actions/updateBusiness.js
import { validatePermission, businessRules } from '@/lib/permissions';

async function getResources(businessId) {
  const [business, departments] = await Promise.all([
    prisma.business.findUnique({
      where: { id: businessId }
    }),
    prisma.department.findMany({
      where: { businessId }
    })
  ]);

  return { business, departments };
}

export async function updateBusiness(businessId, data) {
  const resources = await getResources(businessId);

  await validatePermission(
    [businessRules.owner],
    { resources }
  );

  // Proceed with update
  return prisma.business.update({
    where: { id: businessId },
    data
  });
}
```

## Advanced Usage

### Combining Multiple Rules

```javascript
const complexRule = createAccessRule()
  .addCondition('role', 'business')
  .addDynamicCondition(({ claims, resources }) => {
    // Check business ownership
    const businessIds = claims.businessOwner || [];
    return businessIds.includes(resources.business?.id);
  })
  .addDynamicCondition(({ claims, resources }) => {
    // Check subscription status
    return resources.business?.subscriptionStatus === 'active';
  })
  .addDynamicCondition(({ claims, resources }) => {
    // Check feature access
    const features = claims.features || [];
    return features.includes(resources.feature?.id);
  })
  .build();
```

### Resource-Based Validation

```javascript
const projectRule = createAccessRule()
  .addCondition('role', 'business')
  .addDynamicCondition(({ claims, resources }) => {
    // Check business access
    const businessIds = claims.businessOwner || [];
    return businessIds.includes(resources.project?.businessId);
  })
  .addDynamicCondition(({ claims, resources }) => {
    // Check project access
    const projectIds = claims.projectManager || [];
    return projectIds.includes(resources.project?.id);
  })
  .build();

// Usage
async function updateProject(projectId, data) {
  const project = await prisma.project.findUnique({
    where: { id: projectId },
    include: { business: true }
  });

  await validatePermission(
    [projectRule],
    { 
      resources: { 
        project,
        business: project.business 
      } 
    }
  );

  // Proceed with update
}
```

## Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

## License

MIT © [Taukala Sdn Bhd]
