Merge pull request 'codex/permissions-refactoring' (#26) from codex/permissions-refactoring into main
linter / quality (push) Successful in 1m15s
tests / ci (8.4) (push) Successful in 1m23s

Reviewed-on: OpenSource/KanbanGit#26
Reviewed-by: Eric <eric-peters6@gmx.de>
This commit was merged in pull request #26.
This commit is contained in:
2026-04-13 17:25:01 +00:00
86 changed files with 2203 additions and 985 deletions
@@ -94,7 +94,7 @@ Check sibling files, related controllers, models, or tests for established patte
### 9. Queue & Job Patterns → `rules/queue-jobs.md`
- `retry_after` must exceed job `timeout`; use exponential backoff `[1, 5, 10]`
- `ShouldBeUnique` to prevent duplicates; `WithoutOverlapping::untilProcessing()` for concurrency
- `ShouldBeUnique` to prevent duplicates; `ShouldBeUniqueUntilProcessing` for early lock release
- Always implement `failed()`; with `retryUntil()`, set `$tries = 0`
- `RateLimited` middleware for external API calls; `Bus::batch()` for related jobs
- Horizon for complex multi-queue scenarios
@@ -82,7 +82,7 @@ $this->app->bind(PaymentGateway::class, StripeGateway::class);
## Default Sort by Descending
When no explicit order is specified, sort by `id` or `created_at` descending. Explicit ordering prevents cross-database inconsistencies between MySQL and Postgres.
When no explicit order is specified, sort by `id` or `created_at` descending. Without an explicit `ORDER BY`, row order is undefined.
Incorrect:
```php
@@ -2,7 +2,7 @@
## Use `Cache::remember()` Instead of Manual Get/Put
Atomic pattern prevents race conditions and removes boilerplate.
Cleaner cache-aside pattern that removes boilerplate. use `Cache::lock()` for race conditions.
Incorrect:
```php
@@ -2,7 +2,7 @@
## `env()` Only in Config Files
Direct `env()` calls return `null` when config is cached.
Direct `env()` calls may return `null` when config is cached.
Incorrect:
```php
@@ -29,7 +29,11 @@ class InvoicePaid extends Notification implements ShouldQueue
## Use `afterCommit()` on Notifications in Transactions
Same race condition as events — the queued notification job may run before the transaction commits.
Same race condition as events — call `afterCommit()` to delay dispatch until the transaction commits.
```php
$user->notify((new InvoicePaid($invoice))->afterCommit());
```
## Route Notification Channels to Dedicated Queues
@@ -52,7 +52,7 @@ $response = Http::retry([100, 500, 1000])
Only retry on specific errors:
```php
$response = Http::retry(3, 100, function (Exception $exception, PendingRequest $request) {
$response = Http::retry(3, 100, function (Throwable $exception, PendingRequest $request) {
return $exception instanceof ConnectionException
|| ($exception instanceof RequestException && $exception->response->serverError());
})->post('https://api.example.com/data');
@@ -10,7 +10,7 @@ A queued mailable dispatched inside a transaction may process before the commit.
## Use `assertQueued()` Not `assertSent()` for Queued Mailables
`Mail::assertSent()` only catches synchronous mail. Queued mailables silently pass `assertSent`, giving false confidence.
`Mail::assertSent()` only catches synchronous mail. Queued mailables fail `assertSent` with a "Did you mean to use assertQueued()?" hint.
Incorrect: `Mail::assertSent(OrderShipped::class);` when mailable implements `ShouldQueue`.
@@ -106,25 +106,23 @@ When using time-based retry limits, set `$tries = 0` to avoid premature failure.
```php
public $tries = 0;
public function retryUntil(): DateTime
public function retryUntil(): \DateTimeInterface
{
return now()->addHours(4);
}
```
## Use `WithoutOverlapping::untilProcessing()`
## Use `ShouldBeUniqueUntilProcessing` for Early Lock Release
Prevents concurrent execution while allowing new instances to queue.
`ShouldBeUnique` holds the lock until the job completes. `ShouldBeUniqueUntilProcessing` releases it when processing starts, allowing new instances to queue.
```php
public function middleware(): array
class UpdateSearchIndex implements ShouldQueue, ShouldBeUniqueUntilProcessing
{
return [new WithoutOverlapping($this->product->id)->untilProcessing()];
// Lock releases when processing begins, not when it finishes
}
```
Without `untilProcessing()`, the lock extends through queue wait time. With it, the lock releases when processing starts.
## Use Horizon for Complex Queue Scenarios
Use Laravel Horizon when you need monitoring, auto-scaling, failure tracking, or multiple queues with different priorities.
@@ -36,7 +36,8 @@ Use `Route::resource()` or `apiResource()` for RESTful endpoints.
```php
Route::resource('posts', PostController::class);
Route::apiResource('api/posts', Api\PostController::class);
// In routes/api.php — the /api prefix is applied automatically
Route::apiResource('posts', Api\PostController::class);
```
## Keep Controllers Thin
@@ -32,7 +32,7 @@ Use policies or gates in controllers. Never skip authorization.
Incorrect:
```php
public function update(Request $request, Post $post)
public function update(UpdatePostRequest $request, Post $post)
{
$post->update($request->validated());
}
@@ -90,7 +90,7 @@ Correct:
## CSRF Protection
Include `@csrf` in all POST/PUT/DELETE Blade forms. Not needed in Inertia.
Include `@csrf` in all POST/PUT/DELETE Blade forms. In Inertia apps, the `@csrf` directive is automatically applied.
Incorrect:
```blade
@@ -121,7 +121,7 @@ Route::post('/login', LoginController::class)->middleware('throttle:login');
## Validate File Uploads
Validate MIME type, extension, and size. Never trust client-provided filenames.
Validate extension, MIME type, and size. The `mimes` rule checks extensions; use `mimetypes` for actual MIME type validation. Never trust client-provided filenames.
```php
public function rules(): array
Binary file not shown.
@@ -2,7 +2,7 @@
## Use `LazilyRefreshDatabase` Over `RefreshDatabase`
`RefreshDatabase` runs all migrations every test run even when the schema hasn't changed. `LazilyRefreshDatabase` only migrates when needed, significantly speeding up large suites.
`RefreshDatabase` migrates once per process and wraps each test in a rolled-back transaction. `LazilyRefreshDatabase` skips even that first migration if the schema is already up to date.
## Use Model Assertions Over Raw Database Assertions
@@ -1,6 +1,6 @@
---
name: wayfinder-development
description: "Activates whenever referencing backend routes in frontend components. Use when importing from @/actions or @/routes, calling Laravel routes from TypeScript, or working with Wayfinder route functions."
description: "Use this skill for Laravel Wayfinder which auto-generates typed functions for Laravel controllers and routes. ALWAYS use this skill when frontend code needs to call backend routes or controller actions. Trigger when: connecting any React/Vue/Svelte/Inertia frontend to Laravel controllers, routes, building end-to-end features with both frontend and backend, wiring up forms or links to backend endpoints, fixing route-related TypeScript errors, importing from @/actions or @/routes, or running wayfinder:generate. Use Wayfinder route functions instead of hardcoded URLs. Covers: wayfinder() vite plugin, .url()/.get()/.post()/.form(), query params, route model binding, tree-shaking. Do not use for backend-only task"
license: MIT
metadata:
author: laravel
-3
View File
@@ -36,9 +36,6 @@ jobs:
- name: Run Pint
run: composer lint
- name: Format Frontend
run: npm run format
- name: Lint Frontend
run: npm run lint
+8 -21
View File
@@ -32,12 +32,11 @@ This application is a Laravel application and its main Laravel ecosystems packag
This project has domain-specific skills available. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
- `fortify-development` — ACTIVATE when the user works on authentication in Laravel. This includes login, registration, password reset, email verification, two-factor authentication (2FA/TOTP/QR codes/recovery codes), profile updates, password confirmation, or any auth-related routes and controllers. Activate when the user mentions Fortify, auth, authentication, login, register, signup, forgot password, verify email, 2FA, or references app/Actions/Fortify/, CreateNewUser, UpdateUserProfileInformation, FortifyServiceProvider, config/fortify.php, or auth guards. Fortify is the frontend-agnostic authentication backend for Laravel that registers all auth routes and controllers. Also activate when building SPA or headless authentication, customizing login redirects, overriding response contracts like LoginResponse, or configuring login throttling. Do NOT activate for Laravel Passport (OAuth2 API tokens), Socialite (OAuth social login), or non-auth Laravel features.
- `laravel-best-practices` — Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns.
- `wayfinder-development` — Activates whenever referencing backend routes in frontend components. Use when importing from @/actions or @/routes, calling Laravel routes from TypeScript, or working with Wayfinder route functions.
- `wayfinder-development` — Use this skill for Laravel Wayfinder which auto-generates typed functions for Laravel controllers and routes. ALWAYS use this skill when frontend code needs to call backend routes or controller actions. Trigger when: connecting any React/Vue/Svelte/Inertia frontend to Laravel controllers, routes, building end-to-end features with both frontend and backend, wiring up forms or links to backend endpoints, fixing route-related TypeScript errors, importing from @/actions or @/routes, or running wayfinder:generate. Use Wayfinder route functions instead of hardcoded URLs. Covers: wayfinder() vite plugin, .url()/.get()/.post()/.form(), query params, route model binding, tree-shaking. Do not use for backend-only task
- `inertia-vue-development` — Develops Inertia.js v2 Vue client-side applications. Activates when creating Vue pages, forms, or navigation; using <Link>, <Form>, useForm, or router; working with deferred props, prefetching, or polling; or when user mentions Vue with Inertia, Vue pages, Vue forms, or Vue navigation.
- `tailwindcss-development` — Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS.
- `fortify-development` — ACTIVATE when the user works on authentication in Laravel. This includes login, registration, password reset, email verification, two-factor authentication (2FA/TOTP/QR codes/recovery codes), profile updates, password confirmation, or any auth-related routes and controllers. Activate when the user mentions Fortify, auth, authentication, login, register, signup, forgot password, verify email, 2FA, or references app/Actions/Fortify/, CreateNewUser, UpdateUserProfileInformation, FortifyServiceProvider, config/fortify.php, or auth guards. Fortify is the frontend-agnostic authentication backend for Laravel that registers all auth routes and controllers. Also activate when building SPA or headless authentication, customizing login redirects, overriding response contracts like LoginResponse, or configuring login throttling. Do NOT activate for Laravel Passport (OAuth2 API tokens), Socialite (OAuth social login), or non-auth Laravel features.
- `project-verification-discipline` — Always activate for this project when changing Vue or TypeScript files, Laravel models, or Laravel controllers. It enforces final type-checking for Vue/TS changes and stricter migration, factory, seeder, and PHPUnit verification for model and controller work.
## Conventions
@@ -48,15 +47,6 @@ This project has domain-specific skills available. You MUST activate the relevan
## Verification Scripts
- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.
- Every change to Vue or TypeScript files must end with `npm run types:check`.
- Every new or modified Laravel model must be checked for the required migration changes. If schema or persisted data shape changes are needed, include the migration in the same task.
- Every new Laravel model must include a factory.
- Every modified Laravel model must keep its factory up to date with the current model shape and required attributes.
- Every new Laravel model must be covered by one or more seeders that use factories to generate dummy data.
- Every modified Laravel model must keep its related seeders up to date when the model shape, required attributes, or relationships change.
- Every new or modified Laravel model must have PHPUnit coverage created or updated.
- Every new or modified Laravel controller must have PHPUnit coverage created or updated.
- When a model or controller is added or changed, run the affected PHPUnit tests before finalizing.
## Application Structure & Architecture
@@ -112,7 +102,7 @@ This project has domain-specific skills available. You MUST activate the relevan
- Execute PHP in app context for debugging and testing code. Do not create models without user approval, prefer tests with factories instead. Prefer existing Artisan commands over custom tinker code.
- Always use single quotes to prevent shell expansion: `php artisan tinker --execute 'Your::code();'`
- Double quotes for PHP strings inside: `php artisan tinker --execute 'User::where("active", true)->count();'`
- Double quotes for PHP strings inside: `php artisan tinker --execute 'User::where("active", true)->count();'`
=== php rules ===
@@ -177,17 +167,15 @@ This project has domain-specific skills available. You MUST activate the relevan
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
## Deployment
- Laravel can be deployed using [Laravel Cloud](https://cloud.laravel.com/), which is the fastest way to deploy and scale production Laravel applications.
=== wayfinder/core rules ===
# Laravel Wayfinder
Wayfinder generates TypeScript functions for Laravel routes. Import from `@/actions/` (controllers) or `@/routes/` (named routes).
- IMPORTANT: Activate `wayfinder-development` skill whenever referencing backend routes in frontend components.
- Invokable Controllers: `import StorePost from '@/actions/.../StorePostController'; StorePost()`.
- Parameter Binding: Detects route keys (`{post:slug}`) — `show({ slug: "my-post" })`.
- Query Merging: `show(1, { mergeQuery: { page: 2, sort: null } })` merges with current URL, `null` removes params.
- Inertia: Use `.form()` with `<Form>` component or `form.submit(store())` with useForm.
Use Wayfinder to generate TypeScript functions for Laravel routes. Import from `@/actions/` (controllers) or `@/routes/` (named routes).
=== pint/core rules ===
@@ -219,7 +207,6 @@ Wayfinder generates TypeScript functions for Laravel routes. Import from `@/acti
# Inertia + Vue
Vue components must have a single root element.
- IMPORTANT: Activate `inertia-vue-development` when working with Inertia Vue client-side patterns.
</laravel-boost-guidelines>
@@ -0,0 +1,343 @@
<?php
namespace App\Actions\Permissions;
use App\Enums\Permission;
use App\Models\PermissionAssignment;
use App\Models\Project;
use App\Models\Team;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
class PermissionService
{
/**
* @param array<int, Permission|string> $permissions
*/
public function sync(Model $assignee, ?Model $scope, array $permissions): void
{
$normalizedPermissions = $this->normalizePermissions($permissions, $assignee, $scope);
DB::transaction(function () use ($assignee, $normalizedPermissions, $scope): void {
$this->assignmentQuery($assignee, $scope)->delete();
if ($normalizedPermissions === []) {
return;
}
PermissionAssignment::query()->insert(
array_map(
fn (Permission $permission): array => [
'permission' => $permission->value,
'assignee_id' => $assignee->getKey(),
'assignee_type' => $assignee->getMorphClass(),
'created_at' => now(),
'scope_id' => $scope?->getKey(),
'scope_type' => $scope?->getMorphClass(),
'updated_at' => now(),
],
$normalizedPermissions,
),
);
});
}
public function grant(Model $assignee, Permission|string $permission, ?Model $scope = null): void
{
$normalizedPermission = $this->normalizePermission($permission);
$this->assertAssignmentContext($normalizedPermission, $assignee, $scope);
$alreadyExists = $this->assignmentQuery($assignee, $scope)
->where('permission', $normalizedPermission->value)
->exists();
if ($alreadyExists) {
return;
}
PermissionAssignment::query()->create([
'permission' => $normalizedPermission->value,
'assignee_id' => $assignee->getKey(),
'assignee_type' => $assignee->getMorphClass(),
'scope_id' => $scope?->getKey(),
'scope_type' => $scope?->getMorphClass(),
]);
}
public function revoke(Model $assignee, Permission|string $permission, ?Model $scope = null): void
{
$normalizedPermission = $this->normalizePermission($permission);
$this->assertAssignmentContext($normalizedPermission, $assignee, $scope);
$this->assignmentQuery($assignee, $scope)
->where('permission', $normalizedPermission->value)
->delete();
}
public function allows(User $user, Permission|string $permission, ?Model $scope = null): bool
{
$requiredPermission = $this->normalizePermission($permission);
return collect($this->effectivePermissions($user, $scope))
->contains(fn (Permission $effectivePermission): bool => $effectivePermission === $requiredPermission);
}
/**
* @return array<int, Permission>
*/
public function effectivePermissions(User $user, ?Model $scope = null): array
{
if ($this->hasGlobalAdmin($user)) {
return Permission::cases();
}
/** @var Collection<int, Permission> $permissions */
$permissions = $this->relevantAssignments($user, $scope)
->map(fn (PermissionAssignment $assignment): Permission => $assignment->permission)
->values();
return $this->expandPermissions($permissions->all());
}
/**
* @return array<int, array{label: string, value: string}>
*/
public function permissionOptions(Model|string $assignee, Model|string|null $scope): array
{
return Permission::options(
Permission::assignableFor($assignee, $scope),
);
}
/**
* @return array<string, array<int, string>>
*/
public function assignmentMap(string $assigneeClass, ?Model $scope = null): array
{
/** @var Model $assignee */
$assignee = new $assigneeClass;
/** @var Collection<int, PermissionAssignment> $assignments */
$assignments = PermissionAssignment::query()
->select(['assignee_id', 'permission'])
->where('assignee_type', $assignee->getMorphClass())
->tap(fn (Builder $query): Builder => $this->applyScopeConstraint($query, $scope))
->orderBy('permission')
->get();
return $assignments
->groupBy(fn (PermissionAssignment $assignment): int => (int) $assignment->assignee_id)
->map(fn (Collection $group): array => $group
->map(fn (PermissionAssignment $assignment): string => $assignment->permission->value)
->values()
->all())
->all();
}
/**
* @return array<int, int>
*/
public function viewableProjectIds(User $user): array
{
if ($this->hasGlobalAdmin($user)) {
return Project::query()
->pluck('id')
->map(fn (mixed $projectId): int => (int) $projectId)
->all();
}
/** @var Collection<int, PermissionAssignment> $assignments */
$assignments = PermissionAssignment::query()
->select(['scope_id', 'permission'])
->where('scope_type', (new Project)->getMorphClass())
->where(function (Builder $query) use ($user): void {
$query->where(function (Builder $directAssignmentsQuery) use ($user): void {
$directAssignmentsQuery
->where('assignee_type', $user->getMorphClass())
->where('assignee_id', $user->getKey());
});
$teamIds = $user->teams()
->pluck('teams.id')
->map(fn (mixed $teamId): int => (int) $teamId)
->all();
if ($teamIds !== []) {
$query->orWhere(function (Builder $teamAssignmentsQuery) use ($teamIds): void {
$teamAssignmentsQuery
->where('assignee_type', (new Team)->getMorphClass())
->whereIn('assignee_id', $teamIds);
});
}
})
->get();
return $assignments
->filter(fn (PermissionAssignment $assignment): bool => $assignment->permission->includes(Permission::ProjectRead))
->pluck('scope_id')
->filter()
->map(fn (mixed $projectId): int => (int) $projectId)
->unique()
->values()
->all();
}
/**
* @return array<int, int>
*/
public function manageableTeamIds(User $user): array
{
if ($this->hasGlobalAdmin($user)) {
return Team::query()
->pluck('id')
->map(fn (mixed $teamId): int => (int) $teamId)
->all();
}
/** @var Collection<int, PermissionAssignment> $assignments */
$assignments = PermissionAssignment::query()
->select(['scope_id', 'permission'])
->where('assignee_type', $user->getMorphClass())
->where('assignee_id', $user->getKey())
->where('scope_type', (new Team)->getMorphClass())
->get();
return $assignments
->filter(fn (PermissionAssignment $assignment): bool => $assignment->permission->includes(Permission::TeamManage))
->pluck('scope_id')
->filter()
->map(fn (mixed $teamId): int => (int) $teamId)
->unique()
->values()
->all();
}
public function canManageAnyTeam(User $user): bool
{
return $this->manageableTeamIds($user) !== [];
}
private function hasGlobalAdmin(User $user): bool
{
return PermissionAssignment::query()
->where('assignee_type', $user->getMorphClass())
->where('assignee_id', $user->getKey())
->whereNull('scope_type')
->whereNull('scope_id')
->where('permission', Permission::GlobalAdmin->value)
->exists();
}
/**
* @return Collection<int, PermissionAssignment>
*/
private function relevantAssignments(User $user, ?Model $scope): Collection
{
$query = PermissionAssignment::query();
$this->applyScopeConstraint($query, $scope);
$query->where(function (Builder $permissionQuery) use ($scope, $user): void {
$permissionQuery->where(function (Builder $directAssignmentsQuery) use ($user): void {
$directAssignmentsQuery
->where('assignee_type', $user->getMorphClass())
->where('assignee_id', $user->getKey());
});
if ($scope instanceof Project) {
$teamIds = $user->teams()
->pluck('teams.id')
->map(fn (mixed $teamId): int => (int) $teamId)
->all();
if ($teamIds !== []) {
$permissionQuery->orWhere(function (Builder $teamAssignmentsQuery) use ($teamIds): void {
$teamAssignmentsQuery
->where('assignee_type', (new Team)->getMorphClass())
->whereIn('assignee_id', $teamIds);
});
}
}
});
return $query->get();
}
/**
* @param array<int, Permission|string> $permissions
* @return array<int, Permission>
*/
private function normalizePermissions(array $permissions, Model $assignee, ?Model $scope): array
{
return collect($permissions)
->map(fn (Permission|string $permission): Permission => $this->normalizePermission($permission))
->each(fn (Permission $permission): bool => $this->assertAssignmentContext($permission, $assignee, $scope))
->unique(fn (Permission $permission): string => $permission->value)
->values()
->all();
}
private function normalizePermission(Permission|string $permission): Permission
{
if ($permission instanceof Permission) {
return $permission;
}
return Permission::from(trim($permission));
}
private function assertAssignmentContext(Permission $permission, Model $assignee, ?Model $scope): bool
{
if (! $permission->isAssignableTo($assignee) || ! $permission->matchesScope($scope)) {
throw new InvalidArgumentException("The {$permission->value} permission cannot be assigned in this context.");
}
return true;
}
private function assignmentQuery(Model $assignee, ?Model $scope): Builder
{
$query = PermissionAssignment::query()
->where('assignee_type', $assignee->getMorphClass())
->where('assignee_id', $assignee->getKey());
$this->applyScopeConstraint($query, $scope);
return $query;
}
private function applyScopeConstraint(Builder $query, ?Model $scope): Builder
{
if ($scope === null) {
return $query
->whereNull('scope_type')
->whereNull('scope_id');
}
return $query
->where('scope_type', $scope->getMorphClass())
->where('scope_id', $scope->getKey());
}
/**
* @param array<int, Permission> $permissions
* @return array<int, Permission>
*/
private function expandPermissions(array $permissions): array
{
$expandedPermissions = [];
foreach ($permissions as $permission) {
$expandedPermissions[$permission->value] = $permission;
foreach ($permission->impliedPermissions() as $impliedPermission) {
$expandedPermissions[$impliedPermission->value] = $impliedPermission;
}
}
return array_values($expandedPermissions);
}
}
@@ -1,247 +0,0 @@
<?php
namespace App\Actions\ProjectPermissions;
use App\Models\Project;
use App\Models\Team;
use App\Models\User;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
class ProjectPermissionService
{
/**
* @var array<int, string>
*/
public const PERMISSIONS = [
'create',
'view',
'edit',
'delete',
];
/**
* @return array<int, string>
*/
public static function permissions(): array
{
return self::PERMISSIONS;
}
/**
* @return array<int, array{label: string, value: string}>
*/
public function permissionOptions(): array
{
return collect(self::PERMISSIONS)
->map(fn (string $permission): array => [
'label' => Str::headline($permission),
'value' => $permission,
])
->all();
}
public function grantAllProjectPermissions(Project $project, User $user): void
{
$this->syncUserPermissions($project, $user, self::PERMISSIONS);
}
/**
* @param array<int, string> $permissions
*/
public function syncUserPermissions(Project $project, User $user, array $permissions): void
{
$sanitizedPermissions = $this->sanitizePermissions($permissions);
DB::transaction(function () use ($project, $user, $sanitizedPermissions): void {
DB::table('project_user_permissions')
->where('project_id', $project->id)
->where('user_id', $user->id)
->delete();
if ($sanitizedPermissions === []) {
return;
}
DB::table('project_user_permissions')->insert(
array_map(
fn (string $permission): array => [
'project_id' => $project->id,
'user_id' => $user->id,
'permission' => $permission,
'created_at' => now(),
'updated_at' => now(),
],
$sanitizedPermissions,
),
);
});
}
/**
* @param array<int, string> $permissions
*/
public function syncTeamPermissions(Project $project, Team $team, array $permissions): void
{
$sanitizedPermissions = $this->sanitizePermissions($permissions);
DB::transaction(function () use ($project, $team, $sanitizedPermissions): void {
DB::table('project_team_permissions')
->where('project_id', $project->id)
->where('team_id', $team->id)
->delete();
if ($sanitizedPermissions === []) {
return;
}
DB::table('project_team_permissions')->insert(
array_map(
fn (string $permission): array => [
'project_id' => $project->id,
'team_id' => $team->id,
'permission' => $permission,
'created_at' => now(),
'updated_at' => now(),
],
$sanitizedPermissions,
),
);
});
}
/**
* @param array<int, int> $userIds
*/
public function syncTeamMembers(Team $team, array $userIds): void
{
$team->users()->sync($userIds);
}
public function userHasPermission(User $user, Project $project, string $permission): bool
{
if ($user->is_admin) {
return true;
}
$sanitizedPermission = Str::lower(trim($permission));
if (! in_array($sanitizedPermission, self::PERMISSIONS, true)) {
return false;
}
$hasDirectPermission = DB::table('project_user_permissions')
->where('project_id', $project->id)
->where('user_id', $user->id)
->where('permission', $sanitizedPermission)
->exists();
if ($hasDirectPermission) {
return true;
}
return DB::table('project_team_permissions')
->join('team_user', 'team_user.team_id', '=', 'project_team_permissions.team_id')
->where('project_team_permissions.project_id', $project->id)
->where('team_user.user_id', $user->id)
->where('project_team_permissions.permission', $sanitizedPermission)
->exists();
}
public function userCanViewProject(User $user, Project $project): bool
{
if ($user->is_admin) {
return true;
}
return in_array($project->id, $this->viewableProjectIds($user), true);
}
public function userCanManageProjectAccess(User $user, Project $project): bool
{
return $this->userHasPermission($user, $project, 'edit');
}
/**
* @return array<int, int>
*/
public function viewableProjectIds(User $user): array
{
if ($user->is_admin) {
return Project::query()->pluck('id')->all();
}
$directProjectIds = DB::table('project_user_permissions')
->where('user_id', $user->id)
->pluck('project_id');
$teamProjectIds = DB::table('project_team_permissions')
->join('team_user', 'team_user.team_id', '=', 'project_team_permissions.team_id')
->where('team_user.user_id', $user->id)
->pluck('project_team_permissions.project_id');
return $directProjectIds
->merge($teamProjectIds)
->map(fn (mixed $projectId): int => (int) $projectId)
->unique()
->values()
->all();
}
/**
* @return array<string, array<int, string>>
*/
public function userPermissionMap(Project $project): array
{
/** @var Collection<int, object{user_id: int, permission: string}> $permissions */
$permissions = DB::table('project_user_permissions')
->select(['user_id', 'permission'])
->where('project_id', $project->id)
->orderBy('permission')
->get();
return $permissions
->groupBy(fn (object $permission): int => (int) $permission->user_id)
->map(fn (Collection $group): array => $group
->pluck('permission')
->values()
->all())
->all();
}
/**
* @return array<string, array<int, string>>
*/
public function teamPermissionMap(Project $project): array
{
/** @var Collection<int, object{team_id: int, permission: string}> $permissions */
$permissions = DB::table('project_team_permissions')
->select(['team_id', 'permission'])
->where('project_id', $project->id)
->orderBy('permission')
->get();
return $permissions
->groupBy(fn (object $permission): int => (int) $permission->team_id)
->map(fn (Collection $group): array => $group
->pluck('permission')
->values()
->all())
->all();
}
/**
* @param array<int, string> $permissions
* @return array<int, string>
*/
private function sanitizePermissions(array $permissions): array
{
return collect($permissions)
->map(fn (mixed $permission): string => Str::lower(trim((string) $permission)))
->filter(fn (string $permission): bool => in_array($permission, self::PERMISSIONS, true))
->unique()
->values()
->all();
}
}
@@ -0,0 +1,34 @@
<?php
namespace App\Console\Commands;
use App\Enums\Permission;
use App\Models\User;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Hash;
#[Signature('app:create-initial-admin-user {name=Admin} {email=admin@kanbangit.test} {password=test}')]
#[Description('Command description')]
class CreateInitialAdminUser extends Command
{
/**
* Execute the console command.
*/
public function handle()
{
$user = User::create([
'name' => $this->argument('name'),
'email' => $this->argument('email'),
'password' => Hash::make($this->argument('password')),
]);
$this->info('User created');
$user->permissionAssignments()->create([
'permission' => Permission::GlobalAdmin,
]);
return self::SUCCESS;
}
}
+173
View File
@@ -0,0 +1,173 @@
<?php
namespace App\Enums;
use App\Models\Project;
use App\Models\Team;
use App\Models\User;
use Illuminate\Database\Eloquent\Model;
enum Permission: string
{
case GlobalAdmin = 'global:admin';
case ProjectCreate = 'project:create';
case ProjectRead = 'project:read';
case ProjectWrite = 'project:write';
case ProjectAdmin = 'project:admin';
case TeamCreate = 'team:create';
case TeamInvite = 'team:invite';
case TeamManage = 'team:manage';
case TeamAdmin = 'team:admin';
public function label(): string
{
return match ($this) {
self::GlobalAdmin => 'Global admin',
self::ProjectCreate => 'Project create',
self::ProjectRead => 'Project read',
self::ProjectWrite => 'Project write',
self::ProjectAdmin => 'Project admin',
self::TeamCreate => 'Team create',
self::TeamInvite => 'Team invite',
self::TeamManage => 'Team manage',
self::TeamAdmin => 'Team admin',
};
}
public function scope(): PermissionScope
{
return match ($this) {
self::GlobalAdmin, self::ProjectCreate, self::TeamCreate => PermissionScope::Global,
self::ProjectRead, self::ProjectWrite, self::ProjectAdmin => PermissionScope::Project,
self::TeamInvite, self::TeamManage, self::TeamAdmin => PermissionScope::Team,
};
}
/**
* @return array<int, self>
*/
public function impliedPermissions(): array
{
return match ($this) {
self::GlobalAdmin => array_values(array_filter(
self::cases(),
fn (self $permission): bool => $permission !== self::GlobalAdmin,
)),
self::ProjectAdmin => [self::ProjectWrite, self::ProjectRead],
self::ProjectWrite => [self::ProjectRead],
self::TeamAdmin => [self::TeamManage, self::TeamInvite],
default => [],
};
}
public function includes(self $permission): bool
{
if ($this === $permission) {
return true;
}
return array_any($this->impliedPermissions(), fn ($impliedPermission) => $impliedPermission->includes($permission));
}
public function isAssignableTo(Model|string $assignee): bool
{
$assigneeClass = $assignee instanceof Model
? $assignee::class
: $assignee;
return match ($this->scope()) {
PermissionScope::Global => is_a($assigneeClass, User::class, true),
PermissionScope::Project => is_a($assigneeClass, User::class, true) || is_a($assigneeClass, Team::class, true),
PermissionScope::Team => is_a($assigneeClass, User::class, true),
};
}
public function matchesScope(Model|string|null $scope): bool
{
$scopeClass = $scope instanceof Model
? $scope::class
: $scope;
return match ($this->scope()) {
PermissionScope::Global => $scopeClass === null,
PermissionScope::Project => is_a($scopeClass, Project::class, true),
PermissionScope::Team => is_a($scopeClass, Team::class, true),
};
}
/**
* @param array<int, self>|null $permissions
* @return array<int, string>
*/
public static function values(?array $permissions = null): array
{
return array_map(
fn (self $permission): string => $permission->value,
$permissions ?? self::cases(),
);
}
/**
* @return array<int, self>
*/
public static function globalAssignable(): array
{
return [
self::GlobalAdmin,
self::TeamCreate,
self::ProjectCreate,
];
}
/**
* @return array<int, self>
*/
public static function projectAssignable(): array
{
return [
self::ProjectRead,
self::ProjectWrite,
self::ProjectAdmin,
];
}
/**
* @return array<int, self>
*/
public static function teamAssignable(): array
{
return [
self::TeamInvite,
self::TeamManage,
self::TeamAdmin,
];
}
/**
* @return array<int, self>
*/
public static function assignableFor(Model|string $assignee, Model|string|null $scope): array
{
return array_values(array_filter(
self::cases(),
fn (self $permission): bool => $permission->isAssignableTo($assignee)
&& $permission->matchesScope($scope),
));
}
/**
* @param array<int, self> $permissions
* @return array<int, array{label: string, value: string}>
*/
public static function options(array $permissions): array
{
return array_map(
fn (self $permission): array => [
'label' => $permission->label(),
'value' => $permission->value,
],
$permissions,
);
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
namespace App\Enums;
enum PermissionScope: string
{
case Global = 'global';
case Project = 'project';
case Team = 'team';
}
@@ -2,8 +2,9 @@
namespace App\Http\Controllers\Admin;
use App\Actions\Permissions\PermissionService;
use App\Http\Controllers\Controller;
use App\Http\Requests\Admin\UpdateUserAdminStatusRequest;
use App\Http\Requests\Admin\SyncUserGlobalPermissionsRequest;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Arr;
@@ -12,28 +13,30 @@ use Inertia\Response;
class UserManagementController extends Controller
{
public function __construct(
private readonly PermissionService $permissionService,
) {}
public function index(): Response
{
return Inertia::render('admin/users/Index', [
'directPermissions' => $this->permissionService->assignmentMap(User::class),
'permissionOptions' => $this->permissionService->permissionOptions(User::class, null),
'status' => session('status'),
'users' => User::query()
->orderByDesc('is_admin')
->orderBy('name')
->get(),
]);
}
public function update(UpdateUserAdminStatusRequest $request, User $user): RedirectResponse
public function update(SyncUserGlobalPermissionsRequest $request, User $user): RedirectResponse
{
$validated = $request->validated();
$user->forceFill([
'is_admin' => Arr::get($validated, 'is_admin'),
])->save();
$permissions = Arr::get($validated, 'permissions', []);
$status = $user->is_admin
? "{$user->name} now has admin access."
: "{$user->name} no longer has admin access.";
$this->permissionService->sync($user, null, $permissions);
return to_route('admin.users.index')->with('status', $status);
return to_route('admin.users.index')
->with('status', "{$user->name} global permissions were updated.");
}
}
@@ -2,8 +2,9 @@
namespace App\Http\Controllers;
use App\Actions\ProjectPermissions\ProjectPermissionService;
use App\Actions\Permissions\PermissionService;
use App\Actions\Tickets\CreateProjectTicket;
use App\Enums\Permission;
use App\Http\Requests\StoreProjectTicketRequest;
use App\Models\Project;
use App\Models\Ticket;
@@ -16,7 +17,7 @@ class ProjectBacklogController extends Controller
{
public function __construct(
private readonly CreateProjectTicket $createProjectTicket,
private readonly ProjectPermissionService $projectPermissionService,
private readonly PermissionService $permissionService,
) {}
public function index(Request $request, Project $project): Response
@@ -24,14 +25,14 @@ class ProjectBacklogController extends Controller
$user = $request->user();
abort_unless(
$this->projectPermissionService->userCanViewProject($user, $project),
$this->permissionService->allows($user, Permission::ProjectRead, $project),
403,
);
return Inertia::render('projects/Backlog', [
'boardColumns' => $project->boardColumns()->get(),
'canEditTickets' => $this->projectPermissionService->userHasPermission($user, $project, 'edit'),
'canCreateTickets' => $this->projectPermissionService->userHasPermission($user, $project, 'create'),
'canEditTickets' => $this->permissionService->allows($user, Permission::ProjectWrite, $project),
'canCreateTickets' => $this->permissionService->allows($user, Permission::ProjectWrite, $project),
'project' => $project,
'status' => session('status'),
'ticketPriorities' => $project->ticketPriorities()->get(),
@@ -51,7 +52,7 @@ class ProjectBacklogController extends Controller
$validated = $request->validated();
abort_unless(
$this->projectPermissionService->userHasPermission($user, $project, 'create'),
$this->permissionService->allows($user, Permission::ProjectWrite, $project),
403,
);
@@ -2,8 +2,9 @@
namespace App\Http\Controllers;
use App\Actions\ProjectPermissions\ProjectPermissionService;
use App\Actions\Permissions\PermissionService;
use App\Actions\Tickets\CreateTicketWorklogEntry;
use App\Enums\Permission;
use App\Http\Requests\MoveProjectTicketRequest;
use App\Models\Project;
use App\Models\Ticket;
@@ -17,7 +18,7 @@ class ProjectBoardController extends Controller
{
public function __construct(
private readonly CreateTicketWorklogEntry $createTicketWorklogEntry,
private readonly ProjectPermissionService $projectPermissionService,
private readonly PermissionService $permissionService,
) {}
public function index(Request $request, Project $project): Response
@@ -25,7 +26,7 @@ class ProjectBoardController extends Controller
$user = $request->user();
abort_unless(
$this->projectPermissionService->userCanViewProject($user, $project),
$this->permissionService->allows($user, Permission::ProjectRead, $project),
403,
);
@@ -36,7 +37,7 @@ class ProjectBoardController extends Controller
->with(['ticketPriority', 'ticketType']),
])
->get(),
'canEditTickets' => $this->projectPermissionService->userHasPermission($user, $project, 'edit'),
'canEditTickets' => $this->permissionService->allows($user, Permission::ProjectWrite, $project),
'project' => $project,
'status' => session('status'),
]);
@@ -50,7 +51,7 @@ class ProjectBoardController extends Controller
$user = $request->user();
abort_unless(
$this->projectPermissionService->userHasPermission($user, $project, 'edit'),
$this->permissionService->allows($user, Permission::ProjectWrite, $project),
403,
);
@@ -2,7 +2,8 @@
namespace App\Http\Controllers;
use App\Actions\ProjectPermissions\ProjectPermissionService;
use App\Actions\Permissions\PermissionService;
use App\Enums\Permission;
use App\Http\Requests\StoreBoardColumnRequest;
use App\Http\Requests\UpdateBoardColumnRequest;
use App\Models\BoardColumn;
@@ -18,13 +19,13 @@ use Inertia\Response;
class ProjectBoardSettingsController extends Controller
{
public function __construct(
private readonly ProjectPermissionService $projectPermissionService,
private readonly PermissionService $permissionService,
) {}
public function show(Request $request, Project $project): Response
{
abort_unless(
$this->projectPermissionService->userCanManageProjectAccess($request->user(), $project),
$this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project),
403,
);
@@ -40,7 +41,7 @@ class ProjectBoardSettingsController extends Controller
Project $project,
): RedirectResponse {
abort_unless(
$this->projectPermissionService->userCanManageProjectAccess($request->user(), $project),
$this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project),
403,
);
$validated = $request->validated();
@@ -68,7 +69,7 @@ class ProjectBoardSettingsController extends Controller
BoardColumn $boardColumn,
): RedirectResponse {
abort_unless(
$this->projectPermissionService->userCanManageProjectAccess($request->user(), $project),
$this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project),
403,
);
$validated = $request->validated();
@@ -91,7 +92,7 @@ class ProjectBoardSettingsController extends Controller
BoardColumn $boardColumn,
): RedirectResponse {
abort_unless(
$this->projectPermissionService->userCanManageProjectAccess($request->user(), $project),
$this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project),
403,
);
+23 -16
View File
@@ -2,8 +2,9 @@
namespace App\Http\Controllers;
use App\Actions\ProjectPermissions\ProjectPermissionService;
use App\Actions\Permissions\PermissionService;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Enums\Permission;
use App\Http\Requests\DeleteProjectRequest;
use App\Http\Requests\StoreProjectRequest;
use App\Http\Requests\SyncProjectPermissionsRequest;
@@ -20,7 +21,7 @@ class ProjectController extends Controller
{
public function __construct(
private readonly InitializeProjectTicketMetadata $initializeProjectTicketMetadata,
private readonly ProjectPermissionService $projectPermissionService,
private readonly PermissionService $permissionService,
) {}
public function index(Request $request): Response
@@ -30,12 +31,13 @@ class ProjectController extends Controller
$projectQuery = Project::query()
->orderByDesc('id');
if (! $user->is_admin) {
$viewableProjectIds = $this->projectPermissionService->viewableProjectIds($user);
if (! $this->permissionService->allows($user, Permission::GlobalAdmin)) {
$viewableProjectIds = $this->permissionService->viewableProjectIds($user);
if ($viewableProjectIds === []) {
return Inertia::render('projects/Index', [
'projects' => [],
'status' => session('status'),
]);
}
@@ -49,8 +51,13 @@ class ProjectController extends Controller
]);
}
public function create(): Response
public function create(Request $request): Response
{
abort_unless(
$this->permissionService->allows($request->user(), Permission::ProjectCreate),
403,
);
return Inertia::render('projects/Create');
}
@@ -58,7 +65,7 @@ class ProjectController extends Controller
{
$project = Project::query()->create($request->validated());
$this->initializeProjectTicketMetadata->handle($project);
$this->projectPermissionService->grantAllProjectPermissions($project, $request->user());
$this->permissionService->grant($request->user(), Permission::ProjectAdmin, $project);
return to_route('projects.show', $project);
}
@@ -68,12 +75,12 @@ class ProjectController extends Controller
$user = $request->user();
abort_unless(
$this->projectPermissionService->userCanViewProject($user, $project),
$this->permissionService->allows($user, Permission::ProjectRead, $project),
403,
);
return Inertia::render('projects/Show', [
'canManageAccess' => $this->projectPermissionService->userCanManageProjectAccess($user, $project),
'canManageAccess' => $this->permissionService->allows($user, Permission::ProjectAdmin, $project),
'project' => $project,
]);
}
@@ -90,16 +97,16 @@ class ProjectController extends Controller
{
$user = $request->user();
abort_unless(
$this->projectPermissionService->userCanManageProjectAccess($user, $project),
$this->permissionService->allows($user, Permission::ProjectAdmin, $project),
403,
);
return Inertia::render('projects/Access', [
'directPermissions' => $this->projectPermissionService->userPermissionMap($project),
'permissionOptions' => $this->projectPermissionService->permissionOptions(),
'directPermissions' => $this->permissionService->assignmentMap(User::class, $project),
'permissionOptions' => $this->permissionService->permissionOptions(User::class, Project::class),
'project' => $project,
'status' => session('status'),
'teamPermissions' => $this->projectPermissionService->teamPermissionMap($project),
'teamPermissions' => $this->permissionService->assignmentMap(Team::class, $project),
'teams' => Team::query()
->with('users')
->orderBy('name')
@@ -125,14 +132,14 @@ class ProjectController extends Controller
): RedirectResponse {
abort_unless(
$this->projectPermissionService->userCanManageProjectAccess($request->user(), $project),
$this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project),
403,
);
$validated = $request->validated();
$permissions = Arr::get($validated, 'permissions', []);
$this->projectPermissionService->syncUserPermissions($project, $user, $permissions);
$this->permissionService->sync($user, $project, $permissions);
return to_route('projects.access', $project)
->with('status', "{$user->name} permissions were updated.");
@@ -144,14 +151,14 @@ class ProjectController extends Controller
Team $team,
): RedirectResponse {
abort_unless(
$this->projectPermissionService->userCanManageProjectAccess($request->user(), $project),
$this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project),
403,
);
$validated = $request->validated();
$permissions = Arr::get($validated, 'permissions', []);
$this->projectPermissionService->syncTeamPermissions($project, $team, $permissions);
$this->permissionService->sync($team, $project, $permissions);
return to_route('projects.access', $project)
->with('status', "{$team->name} permissions were updated.");
@@ -4,7 +4,8 @@ namespace App\Http\Controllers;
use App\Actions\Git\Data\GitRepository;
use App\Actions\Git\GitProviderManager;
use App\Actions\ProjectPermissions\ProjectPermissionService;
use App\Actions\Permissions\PermissionService;
use App\Enums\Permission;
use App\Http\Requests\StoreRepositoryConnectionRequest;
use App\Http\Requests\UpdateRepositoryConnectionRequest;
use App\Models\Project;
@@ -24,14 +25,14 @@ class ProjectRepositorySettingsController extends Controller
{
public function __construct(
private readonly GitProviderManager $gitProviderManager,
private readonly ProjectPermissionService $projectPermissionService,
private readonly PermissionService $permissionService,
) {}
public function show(Request $request, Project $project): Response
{
$user = $request->user();
abort_unless(
$this->projectPermissionService->userCanManageProjectAccess($user, $project),
$this->permissionService->allows($user, Permission::ProjectAdmin, $project),
403,
);
@@ -66,7 +67,7 @@ class ProjectRepositorySettingsController extends Controller
): RedirectResponse {
$user = $request->user();
abort_unless(
$this->projectPermissionService->userCanManageProjectAccess($user, $project),
$this->permissionService->allows($user, Permission::ProjectAdmin, $project),
403,
);
@@ -96,7 +97,7 @@ class ProjectRepositorySettingsController extends Controller
$user = $request->user();
abort_unless(
$this->projectPermissionService->userCanManageProjectAccess($user, $project),
$this->permissionService->allows($user, Permission::ProjectAdmin, $project),
403,
);
@@ -128,7 +129,7 @@ class ProjectRepositorySettingsController extends Controller
RepositoryConnection $repositoryConnection,
): RedirectResponse {
abort_unless(
$this->projectPermissionService->userCanManageProjectAccess($request->user(), $project),
$this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project),
403,
);
@@ -5,7 +5,8 @@ namespace App\Http\Controllers;
use App\Actions\Git\Data\GitBranch;
use App\Actions\Git\GitProviderManager;
use App\Actions\Git\ValidateGitBranchName;
use App\Actions\ProjectPermissions\ProjectPermissionService;
use App\Actions\Permissions\PermissionService;
use App\Enums\Permission;
use App\Http\Requests\StoreProjectTicketBranchRequest;
use App\Http\Requests\ValidateProjectTicketBranchRequest;
use App\Models\Project;
@@ -23,7 +24,7 @@ class ProjectTicketBranchController extends Controller
public function __construct(
private readonly GitProviderManager $gitProviderManager,
private readonly ValidateGitBranchName $validateGitBranchName,
private readonly ProjectPermissionService $projectPermissionService,
private readonly PermissionService $permissionService,
) {}
public function validate(
@@ -34,7 +35,7 @@ class ProjectTicketBranchController extends Controller
$user = $request->user();
abort_unless(
$this->projectPermissionService->userHasPermission($user, $project, 'edit'),
$this->permissionService->allows($user, Permission::ProjectWrite, $project),
403,
);
@@ -110,7 +111,7 @@ class ProjectTicketBranchController extends Controller
$user = $request->user();
abort_unless(
$this->projectPermissionService->userHasPermission($user, $project, 'edit'),
$this->permissionService->allows($user, Permission::ProjectWrite, $project),
403,
);
@@ -5,8 +5,9 @@ namespace App\Http\Controllers;
use App\Actions\Git\BuildSuggestedTicketBranchName;
use App\Actions\Git\ResolveTicketBranchMatches;
use App\Actions\Git\ResolveTicketPullRequestMatches;
use App\Actions\ProjectPermissions\ProjectPermissionService;
use App\Actions\Permissions\PermissionService;
use App\Actions\Tickets\UpdateProjectTicket;
use App\Enums\Permission;
use App\Http\Requests\UpdateProjectTicketRequest;
use App\Models\Project;
use App\Models\Ticket;
@@ -22,7 +23,7 @@ class ProjectTicketController extends Controller
{
public function __construct(
private readonly BuildSuggestedTicketBranchName $buildSuggestedTicketBranchName,
private readonly ProjectPermissionService $projectPermissionService,
private readonly PermissionService $permissionService,
private readonly ResolveTicketBranchMatches $resolveTicketBranchMatches,
private readonly ResolveTicketPullRequestMatches $resolveTicketPullRequestMatches,
private readonly UpdateProjectTicket $updateProjectTicket,
@@ -33,14 +34,14 @@ class ProjectTicketController extends Controller
$user = $request->user();
abort_unless(
$this->projectPermissionService->userCanViewProject($user, $project),
$this->permissionService->allows($user, Permission::ProjectRead, $project),
403,
);
$excludedParentIds = [$ticket->id, ...$this->descendantIds($ticket)];
return Inertia::render('projects/Ticket', [
'canEditTicket' => $this->projectPermissionService->userHasPermission($user, $project, 'edit'),
'canEditTicket' => $this->permissionService->allows($user, Permission::ProjectWrite, $project),
'childTicketTree' => $this->buildChildTicketTree($ticket),
'linkedTickets' => $this->linkedTickets($ticket),
'parentTicketOptions' => Ticket::query()
@@ -103,7 +104,7 @@ class ProjectTicketController extends Controller
$user = $request->user();
abort_unless(
$this->projectPermissionService->userHasPermission($user, $project, 'edit'),
$this->permissionService->allows($user, Permission::ProjectWrite, $project),
403,
);
@@ -2,8 +2,9 @@
namespace App\Http\Controllers;
use App\Actions\ProjectPermissions\ProjectPermissionService;
use App\Actions\Permissions\PermissionService;
use App\Actions\Tickets\CreateTicketWorklogEntry;
use App\Enums\Permission;
use App\Http\Requests\StoreTicketLinkRequest;
use App\Models\Project;
use App\Models\Ticket;
@@ -17,7 +18,7 @@ class ProjectTicketLinkController extends Controller
{
public function __construct(
private readonly CreateTicketWorklogEntry $createTicketWorklogEntry,
private readonly ProjectPermissionService $projectPermissionService,
private readonly PermissionService $permissionService,
) {}
public function store(
@@ -28,7 +29,7 @@ class ProjectTicketLinkController extends Controller
$user = $request->user();
abort_unless(
$this->projectPermissionService->userHasPermission($user, $project, 'edit'),
$this->permissionService->allows($user, Permission::ProjectWrite, $project),
403,
);
@@ -93,7 +94,7 @@ class ProjectTicketLinkController extends Controller
$user = $request->user();
abort_unless(
$this->projectPermissionService->userHasPermission($user, $project, 'edit'),
$this->permissionService->allows($user, Permission::ProjectWrite, $project),
403,
);
@@ -2,8 +2,9 @@
namespace App\Http\Controllers;
use App\Actions\ProjectPermissions\ProjectPermissionService;
use App\Actions\Permissions\PermissionService;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Enums\Permission;
use App\Http\Requests\StoreProjectNamedSettingRequest;
use App\Http\Requests\StoreTicketFieldDefinitionRequest;
use App\Http\Requests\UpdateProjectNamedSettingRequest;
@@ -26,7 +27,7 @@ class ProjectTicketMetadataSettingsController extends Controller
{
public function __construct(
private readonly InitializeProjectTicketMetadata $initializeProjectTicketMetadata,
private readonly ProjectPermissionService $projectPermissionService,
private readonly PermissionService $permissionService,
) {}
public function show(Request $request, Project $project): Response
@@ -355,7 +356,7 @@ class ProjectTicketMetadataSettingsController extends Controller
private function abortUnlessCanManage(Request $request, Project $project): void
{
abort_unless(
$this->projectPermissionService->userCanManageProjectAccess($request->user(), $project),
$this->permissionService->allows($request->user(), Permission::ProjectAdmin, $project),
403,
);
}
+53 -11
View File
@@ -2,12 +2,15 @@
namespace App\Http\Controllers;
use App\Actions\ProjectPermissions\ProjectPermissionService;
use App\Actions\Permissions\PermissionService;
use App\Enums\Permission;
use App\Http\Requests\StoreTeamRequest;
use App\Http\Requests\SyncTeamUserPermissionsRequest;
use App\Http\Requests\UpdateTeamMembersRequest;
use App\Models\Team;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Inertia\Inertia;
use Inertia\Response;
@@ -15,17 +18,33 @@ use Inertia\Response;
class TeamController extends Controller
{
public function __construct(
private readonly ProjectPermissionService $projectPermissionService,
private readonly PermissionService $permissionService,
) {}
public function index(): Response
public function index(Request $request): Response
{
$user = $request->user();
$canCreateTeams = $this->permissionService->allows($user, Permission::TeamCreate);
$manageableTeamIds = $this->permissionService->manageableTeamIds($user);
abort_unless($canCreateTeams || $manageableTeamIds !== [], 403);
$teams = Team::query()
->whereIn('id', $manageableTeamIds)
->with('users')
->orderBy('name')
->get();
return Inertia::render('teams/Index', [
'canCreateTeams' => $canCreateTeams,
'permissionOptions' => $this->permissionService->permissionOptions(User::class, Team::class),
'status' => session('status'),
'teams' => Team::query()
->with('users')
->orderBy('name')
->get()
'teamPermissions' => $teams
->mapWithKeys(fn (Team $team): array => [
$team->id => $this->permissionService->assignmentMap(User::class, $team),
])
->all(),
'teams' => $teams
->map(fn (Team $team): array => [
'id' => $team->id,
'name' => $team->name,
@@ -44,19 +63,42 @@ class TeamController extends Controller
{
$team = Team::query()->create($request->validated());
return to_route('admin.teams.index')
return to_route('teams.index')
->with('status', "{$team->name} was created.");
}
public function updateMembers(UpdateTeamMembersRequest $request, Team $team): RedirectResponse
{
$validated = $request->validated();
abort_unless(
$this->permissionService->allows($request->user(), Permission::TeamManage, $team),
403,
);
$validated = $request->validated();
$userIds = Arr::get($validated, 'user_ids', []);
$this->projectPermissionService->syncTeamMembers($team, $userIds);
$team->users()->sync($userIds);
return to_route('admin.teams.index')
return to_route('teams.index')
->with('status', "{$team->name} members were updated.");
}
public function updateUserPermissions(
SyncTeamUserPermissionsRequest $request,
Team $team,
User $user,
): RedirectResponse {
abort_unless(
$this->permissionService->allows($request->user(), Permission::TeamManage, $team),
403,
);
$validated = $request->validated();
$permissions = Arr::get($validated, 'permissions', []);
$this->permissionService->sync($user, $team, $permissions);
return to_route('teams.index')
->with('status', "{$user->name} permissions on {$team->name} were updated.");
}
}
+14 -1
View File
@@ -2,11 +2,17 @@
namespace App\Http\Middleware;
use App\Actions\Permissions\PermissionService;
use App\Enums\Permission;
use Illuminate\Http\Request;
use Inertia\Middleware;
class HandleInertiaRequests extends Middleware
{
public function __construct(
private readonly PermissionService $permissionService,
) {}
/**
* The root template that's loaded on the first page visit.
*
@@ -35,11 +41,18 @@ class HandleInertiaRequests extends Middleware
*/
public function share(Request $request): array
{
$user = $request->user();
return [
...parent::share($request),
'name' => config('app.name'),
'auth' => [
'user' => $request->user(),
'permissions' => [
'canManageTeams' => $user !== null && ($this->permissionService->canManageAnyTeam($user) || $this->permissionService->allows($user, Permission::TeamCreate)),
'globalAdmin' => $user !== null && $this->permissionService->allows($user, Permission::GlobalAdmin),
'projectCreate' => $user !== null && $this->permissionService->allows($user, Permission::ProjectCreate),
],
'user' => $user,
],
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
];
@@ -2,10 +2,12 @@
namespace App\Http\Requests\Admin;
use App\Enums\Permission;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateUserAdminStatusRequest extends FormRequest
class SyncUserGlobalPermissionsRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
@@ -23,7 +25,12 @@ class UpdateUserAdminStatusRequest extends FormRequest
public function rules(): array
{
return [
'is_admin' => ['required', 'boolean'],
'permissions' => ['nullable', 'array'],
'permissions.*' => [
'string',
'distinct',
Rule::in(Permission::values(Permission::globalAssignable())),
],
];
}
}
+1 -1
View File
@@ -11,7 +11,7 @@ class DeleteProjectRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
return $this->user()?->can('access-admin') ?? false;
}
/**
+1 -1
View File
@@ -14,7 +14,7 @@ class StoreProjectRequest extends FormRequest
*/
public function authorize(): bool
{
return $this->user() !== null;
return $this->user()?->can('create-project') ?? false;
}
protected function prepareForValidation(): void
+1 -1
View File
@@ -13,7 +13,7 @@ class StoreTeamRequest extends FormRequest
*/
public function authorize(): bool
{
return $this->user()?->can('access-admin') ?? false;
return $this->user()?->can('create-team') ?? false;
}
/**
@@ -2,7 +2,7 @@
namespace App\Http\Requests;
use App\Actions\ProjectPermissions\ProjectPermissionService;
use App\Enums\Permission;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
@@ -29,7 +29,7 @@ class SyncProjectPermissionsRequest extends FormRequest
'permissions.*' => [
'string',
'distinct',
Rule::in(ProjectPermissionService::permissions()),
Rule::in(Permission::values(Permission::projectAssignable())),
],
];
}
@@ -0,0 +1,36 @@
<?php
namespace App\Http\Requests;
use App\Enums\Permission;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class SyncTeamUserPermissionsRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return $this->user() !== null;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'permissions' => ['nullable', 'array'],
'permissions.*' => [
'string',
'distinct',
Rule::in(Permission::values(Permission::teamAssignable())),
],
];
}
}
@@ -13,7 +13,7 @@ class UpdateTeamMembersRequest extends FormRequest
*/
public function authorize(): bool
{
return $this->user()?->can('access-admin') ?? false;
return $this->user() !== null;
}
/**
+37
View File
@@ -0,0 +1,37 @@
<?php
namespace App\Models;
use App\Enums\Permission;
use Database\Factories\PermissionAssignmentFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\MorphTo;
#[Fillable(['permission', 'assignee_type', 'assignee_id', 'scope_type', 'scope_id'])]
class PermissionAssignment extends Model
{
/** @use HasFactory<PermissionAssignmentFactory> */
use HasFactory;
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'permission' => Permission::class,
];
}
public function assignee(): MorphTo
{
return $this->morphTo();
}
public function scope(): MorphTo
{
return $this->morphTo();
}
}
+13
View File
@@ -8,6 +8,7 @@ use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Support\Str;
#[Fillable(['name', 'key'])]
@@ -16,6 +17,13 @@ class Project extends Model
/** @use HasFactory<ProjectFactory> */
use HasFactory;
protected static function booted(): void
{
static::deleting(function (self $project): void {
$project->permissionAssignments()->delete();
});
}
protected function key(): Attribute
{
return Attribute::make(
@@ -23,6 +31,11 @@ class Project extends Model
);
}
public function permissionAssignments(): MorphMany
{
return $this->morphMany(PermissionAssignment::class, 'scope');
}
public function boardColumns(): HasMany
{
return $this->hasMany(BoardColumn::class)
+19
View File
@@ -7,6 +7,7 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
#[Fillable(['name'])]
class Team extends Model
@@ -14,6 +15,24 @@ class Team extends Model
/** @use HasFactory<TeamFactory> */
use HasFactory;
protected static function booted(): void
{
static::deleting(function (self $team): void {
$team->permissionAssignments()->delete();
$team->scopedPermissionAssignments()->delete();
});
}
public function permissionAssignments(): MorphMany
{
return $this->morphMany(PermissionAssignment::class, 'assignee');
}
public function scopedPermissionAssignments(): MorphMany
{
return $this->morphMany(PermissionAssignment::class, 'scope');
}
public function users(): BelongsToMany
{
return $this->belongsToMany(User::class)
+13 -1
View File
@@ -9,6 +9,7 @@ use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Fortify\TwoFactorAuthenticatable;
@@ -20,6 +21,13 @@ class User extends Authenticatable
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable, TwoFactorAuthenticatable;
protected static function booted(): void
{
static::deleting(function (self $user): void {
$user->permissionAssignments()->delete();
});
}
/**
* Get the attributes that should be cast.
*
@@ -29,12 +37,16 @@ class User extends Authenticatable
{
return [
'email_verified_at' => 'datetime',
'is_admin' => 'boolean',
'password' => 'hashed',
'two_factor_confirmed_at' => 'datetime',
];
}
public function permissionAssignments(): MorphMany
{
return $this->morphMany(PermissionAssignment::class, 'assignee');
}
public function teams(): BelongsToMany
{
return $this->belongsToMany(Team::class)
+19 -1
View File
@@ -2,8 +2,13 @@
namespace App\Providers;
use App\Actions\Permissions\PermissionService;
use App\Enums\Permission;
use App\Models\Project;
use App\Models\Team;
use App\Models\User;
use Carbon\CarbonImmutable;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Gate;
@@ -40,7 +45,20 @@ class AppServiceProvider extends ServiceProvider
*/
protected function configureAuthorization(): void
{
Gate::define('access-admin', fn (User $user): bool => $user->is_admin);
Relation::enforceMorphMap([
'project' => Project::class,
'team' => Team::class,
'user' => User::class,
]);
/** @var PermissionService $permissionService */
$permissionService = $this->app->make(PermissionService::class);
Gate::define('access-admin', fn (User $user): bool => $permissionService->allows($user, Permission::GlobalAdmin));
Gate::define('create-project', fn (User $user): bool => $permissionService->allows($user, Permission::ProjectCreate));
Gate::define('create-team', fn (User $user): bool => $permissionService->allows($user, Permission::TeamCreate));
Gate::define('manage-teams', fn (User $user): bool => $permissionService->canManageAnyTeam($user));
Gate::define('view-teams', fn (User $user): bool => Gate::forUser($user)->any(['create-team', 'manage-teams']));
}
/**
+2 -5
View File
@@ -5,15 +5,12 @@
"guidelines": true,
"mcp": true,
"nightwatch_mcp": false,
"packages": [
"laravel/fortify"
],
"sail": false,
"skills": [
"fortify-development",
"laravel-best-practices",
"wayfinder-development",
"inertia-vue-development",
"tailwindcss-development",
"fortify-development"
"tailwindcss-development"
]
}
Generated
+240 -256
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,96 @@
<?php
namespace Database\Factories;
use App\Enums\Permission;
use App\Models\PermissionAssignment;
use App\Models\Project;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Database\Eloquent\Model;
/**
* @extends Factory<PermissionAssignment>
*/
class PermissionAssignmentFactory extends Factory
{
/**
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'permission' => Permission::ProjectRead->value,
'assignee_id' => User::factory(),
'assignee_type' => (new User)->getMorphClass(),
'scope_id' => Project::factory(),
'scope_type' => (new Project)->getMorphClass(),
];
}
public function forAssignee(Model $assignee): static
{
return $this->state(fn (): array => [
'assignee_id' => $assignee->getKey(),
'assignee_type' => $assignee->getMorphClass(),
]);
}
public function forScope(Model $scope): static
{
return $this->state(fn (): array => [
'scope_id' => $scope->getKey(),
'scope_type' => $scope->getMorphClass(),
]);
}
public function global(): static
{
return $this->state(fn (): array => [
'scope_id' => null,
'scope_type' => null,
]);
}
public function permission(Permission $permission): static
{
return $this->state(fn (): array => [
'permission' => $permission->value,
]);
}
public function globalAdmin(): static
{
return $this->global()->permission(Permission::GlobalAdmin);
}
public function projectAdmin(): static
{
return $this->permission(Permission::ProjectAdmin);
}
public function projectRead(): static
{
return $this->permission(Permission::ProjectRead);
}
public function projectWrite(): static
{
return $this->permission(Permission::ProjectWrite);
}
public function teamAdmin(): static
{
return $this->permission(Permission::TeamAdmin);
}
public function teamInvite(): static
{
return $this->permission(Permission::TeamInvite);
}
public function teamManage(): static
{
return $this->permission(Permission::TeamManage);
}
}
+7 -4
View File
@@ -2,6 +2,7 @@
namespace Database\Factories;
use App\Models\PermissionAssignment;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash;
@@ -28,7 +29,6 @@ class UserFactory extends Factory
'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(),
'email_verified_at' => now(),
'is_admin' => false,
'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10),
'two_factor_secret' => null,
@@ -64,8 +64,11 @@ class UserFactory extends Factory
*/
public function admin(): static
{
return $this->state(fn (array $attributes) => [
'is_admin' => true,
]);
return $this->afterCreating(function (User $user): void {
PermissionAssignment::factory()
->forAssignee($user)
->globalAdmin()
->create();
});
}
}
@@ -1,28 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->boolean('is_admin')->default(false)->after('email_verified_at');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('is_admin');
});
}
};
@@ -1,31 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('project_team_permissions', function (Blueprint $table) {
$table->foreignId('project_id')->constrained()->cascadeOnDelete();
$table->foreignId('team_id')->constrained()->cascadeOnDelete();
$table->string('permission');
$table->timestamps();
$table->primary(['project_id', 'team_id', 'permission']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('project_team_permissions');
}
};
@@ -1,31 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('project_user_permissions', function (Blueprint $table) {
$table->foreignId('project_id')->constrained()->cascadeOnDelete();
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
$table->string('permission');
$table->timestamps();
$table->primary(['project_id', 'user_id', 'permission']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('project_user_permissions');
}
};
@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('permission_assignments', function (Blueprint $table): void {
$table->id();
$table->string('permission', 50);
$table->string('assignee_type', 32);
$table->unsignedBigInteger('assignee_id');
$table->string('scope_type', 32)->nullable();
$table->unsignedBigInteger('scope_id')->nullable();
$table->timestamps();
$table->index(['assignee_type', 'assignee_id']);
$table->index(['scope_type', 'scope_id']);
$table->unique(['permission', 'assignee_type', 'assignee_id', 'scope_type', 'scope_id'], 'permission_assignments_unique');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('permission_assignments');
}
};
+25 -19
View File
@@ -2,7 +2,8 @@
namespace Database\Seeders;
use App\Actions\ProjectPermissions\ProjectPermissionService;
use App\Enums\Permission;
use App\Models\PermissionAssignment;
use App\Models\Project;
use App\Models\Team;
use App\Models\User;
@@ -15,28 +16,33 @@ class ProjectAccessSeeder extends Seeder
*/
public function run(): void
{
$projectPermissionService = app(ProjectPermissionService::class);
$projects = Project::query()->get();
$admins = User::query()
->where('is_admin', true)
->get();
foreach ($admins as $admin) {
foreach ($projects as $project) {
$projectPermissionService->grantAllProjectPermissions($project, $admin);
}
}
$coreProject = Project::query()->where('key', 'CORE')->first();
$deliveryTeam = Team::query()->where('name', 'Delivery Team')->first();
$platformTeam = Team::query()->where('name', 'Platform Team')->first();
$testUser = User::query()->where('email', 'test@example.com')->first();
if ($coreProject !== null && $platformTeam !== null) {
$projectPermissionService->syncTeamPermissions($coreProject, $platformTeam, [
'create',
'edit',
'view',
]);
PermissionAssignment::factory()
->forAssignee($platformTeam)
->forScope($coreProject)
->projectWrite()
->create();
}
if ($platformTeam !== null && $testUser !== null) {
PermissionAssignment::factory()
->forAssignee($testUser)
->forScope($platformTeam)
->teamManage()
->create();
}
if ($deliveryTeam !== null && $testUser !== null) {
PermissionAssignment::factory()
->forAssignee($testUser)
->forScope($deliveryTeam)
->permission(Permission::TeamInvite)
->create();
}
}
}
+15 -2
View File
@@ -2,6 +2,8 @@
namespace Database\Seeders;
use App\Enums\Permission;
use App\Models\PermissionAssignment;
use App\Models\User;
use Illuminate\Database\Seeder;
@@ -12,16 +14,27 @@ class UserSeeder extends Seeder
*/
public function run(): void
{
User::factory()->admin()->create([
$admin = User::factory()->create([
'name' => 'Admin User',
'email' => 'admin@kanbangit.test',
]);
User::factory()->create([
PermissionAssignment::factory()
->forAssignee($admin)
->globalAdmin()
->create();
$testUser = User::factory()->create([
'name' => 'Test User',
'email' => 'test@example.com',
]);
PermissionAssignment::factory()
->forAssignee($testUser)
->global()
->permission(Permission::ProjectCreate)
->create();
User::factory(8)->create();
}
}
+3 -13
View File
@@ -8,14 +8,6 @@
- app/Http/Requests/UpdateProjectTicketRequest.php and other ProjectTicket only to Ticket...
- mehrere actions/quereys in services auslagern
- 03
- permission models? eg.g syncUserPermissions uses only db table
- mit morph auf perm user (z.b. team oder user)
- Project Permissions Service
- syncTeamMembers(Team $team, array $userIds): void → ist wrong here
- gate statt has permission methods
- permission for creating teams without beeing admind -> teamowner
- team roles
- 05
- ticket type remove build in
- → add is parentBool to ticketType -> remove system_key (maybe add slug)
@@ -38,14 +30,9 @@
- (ui friendly example all following or select)
- change Project Backlog Controller → Project Controller
- remove store from project board controller and route projects/{project}/backlog, change to ticket controller, create
- 08
- worklog überarbeiten → type als worklog enum → json je nach enum type
- UpdateProjectTicket aufräumen
- 09
- app/Http/Controllers/ProjectTicketController.php parent ids anderes queren. nicht alle child ids berechnen und excluden -> immer parent vom parent bis null
- later:
- if deleting ticket type select a new ticket type which all existing ticket should get or select delete all tickets of that type
- repository_connections check base Url maybe unnessecary?
@@ -53,3 +40,6 @@
- Git prs replace baseUrl, owner, rperoName with GItReprository
- git php docu replace @ reutrn with data models
- replace Kanbangit with env App Name
- 08
- worklog überarbeiten → type als worklog enum → json je nach enum type
- UpdateProjectTicket aufräumen
-2
View File
@@ -6,8 +6,6 @@
"build": "vite build",
"build:ssr": "vite build && vite build --ssr",
"dev": "vite",
"format": "prettier --write resources/",
"format:check": "prettier --check resources/",
"lint": "eslint . --fix",
"lint:check": "eslint .",
"types:check": "vue-tsc --noEmit"
+5 -3
View File
@@ -23,9 +23,9 @@ import {
SidebarMenuItem,
} from '@/components/ui/sidebar';
import { dashboard } from '@/routes';
import { index as adminTeams } from '@/routes/admin/teams';
import { index as adminUsers } from '@/routes/admin/users';
import { index as projectsIndex } from '@/routes/projects';
import { index as teamsIndex } from '@/routes/teams';
import type { NavItem } from '@/types';
const page = usePage();
@@ -44,16 +44,18 @@ const mainNavItems = computed<NavItem[]>(() => {
},
];
if (page.props.auth.user.is_admin) {
if (page.props.auth.permissions.globalAdmin) {
items.push({
title: 'Users',
href: adminUsers(),
icon: Shield,
});
}
if (page.props.auth.permissions.canManageTeams) {
items.push({
title: 'Teams',
href: adminTeams(),
href: teamsIndex(),
icon: Users,
});
}
+77 -39
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { Form, Head } from '@inertiajs/vue3';
import { Shield, Users } from 'lucide-vue-next';
import { FolderPlus, Shield, Users } from 'lucide-vue-next';
import UserManagementController from '@/actions/App/Http/Controllers/Admin/UserManagementController';
import Heading from '@/components/Heading.vue';
import { Badge } from '@/components/ui/badge';
@@ -16,7 +16,7 @@ import AppLayout from '@/layouts/AppLayout.vue';
import { index as adminUsers } from '@/routes/admin/users';
import type { AdminUsersPageProps, BreadcrumbItem } from '@/types';
defineProps<AdminUsersPageProps>();
const props = defineProps<AdminUsersPageProps>();
const breadcrumbs: BreadcrumbItem[] = [
{
@@ -24,6 +24,9 @@ const breadcrumbs: BreadcrumbItem[] = [
href: adminUsers(),
},
];
const hasPermission = (actorId: number, permission: string) =>
(props.directPermissions[actorId] ?? []).includes(permission);
</script>
<template>
@@ -34,7 +37,7 @@ const breadcrumbs: BreadcrumbItem[] = [
<div class="flex items-start justify-between gap-4">
<Heading
title="User management"
description="Manage application-wide admin access for existing users."
description="Assign the global permissions that unlock platform-wide administration and project creation."
/>
<Badge variant="outline" class="gap-1.5 px-3 py-1">
@@ -54,8 +57,8 @@ const breadcrumbs: BreadcrumbItem[] = [
<CardHeader class="border-b">
<CardTitle>Users</CardTitle>
<CardDescription>
Promote trusted users to platform admins or remove admin
access when it is no longer needed.
Global permissions are assigned directly to users and
apply across the entire application.
</CardDescription>
</CardHeader>
@@ -77,7 +80,9 @@ const breadcrumbs: BreadcrumbItem[] = [
<tr>
<th class="px-4 py-3 font-medium">User</th>
<th class="px-4 py-3 font-medium">Email</th>
<th class="px-4 py-3 font-medium">Role</th>
<th class="px-4 py-3 font-medium">
Current access
</th>
<th
class="px-4 py-3 text-right font-medium"
>
@@ -89,7 +94,7 @@ const breadcrumbs: BreadcrumbItem[] = [
<tr
v-for="user in users"
:key="user.id"
class="bg-background"
class="bg-background align-top"
>
<td class="px-4 py-4">
<div
@@ -102,19 +107,45 @@ const breadcrumbs: BreadcrumbItem[] = [
{{ user.email }}
</td>
<td class="px-4 py-4">
<Badge
:variant="
user.is_admin
? 'default'
: 'secondary'
"
class="gap-1.5"
>
<Shield class="size-3.5" />
{{
user.is_admin ? 'Admin' : 'User'
}}
</Badge>
<div class="flex flex-wrap gap-2">
<Badge
v-if="
hasPermission(
user.id,
'global:admin',
)
"
class="gap-1.5"
>
<Shield class="size-3.5" />
Global admin
</Badge>
<Badge
v-if="
hasPermission(
user.id,
'project:create',
)
"
variant="secondary"
class="gap-1.5"
>
<FolderPlus class="size-3.5" />
Project create
</Badge>
<span
v-if="
(
directPermissions[
user.id
] ?? []
).length === 0
"
class="text-muted-foreground"
>
No direct global permissions
</span>
</div>
</td>
<td class="px-4 py-4">
<div class="flex justify-end">
@@ -124,33 +155,40 @@ const breadcrumbs: BreadcrumbItem[] = [
user.id,
)
"
class="flex flex-wrap justify-end gap-3"
v-slot="{ processing }"
>
<input
type="hidden"
name="is_admin"
:value="
user.is_admin
? '0'
: '1'
"
/>
<label
v-for="option in permissionOptions"
:key="`${user.id}-${option.value}`"
class="border-border inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm"
>
<input
type="checkbox"
name="permissions[]"
:value="option.value"
:checked="
hasPermission(
user.id,
option.value,
)
"
class="border-input text-primary focus:ring-ring size-4 rounded"
/>
<span>{{
option.label
}}</span>
</label>
<div class="w-full sm:hidden" />
<Button
type="submit"
size="sm"
:variant="
user.is_admin
? 'outline'
: 'default'
"
variant="outline"
:disabled="processing"
>
{{
user.is_admin
? 'Remove admin'
: 'Make admin'
}}
Save
</Button>
</Form>
</div>
+1 -1
View File
@@ -60,7 +60,7 @@ const hasPermission = (
<div class="flex flex-wrap gap-3">
<Button
v-if="$page.props.auth.user.is_admin"
v-if="$page.props.auth.permissions.globalAdmin"
as-child
variant="outline"
>
+4 -1
View File
@@ -38,7 +38,10 @@ const breadcrumbs: BreadcrumbItem[] = [
description="Create and browse project spaces that will later host boards, backlog, and settings."
/>
<Button as-child>
<Button
v-if="$page.props.auth.permissions.projectCreate"
as-child
>
<Link :href="create()">
<Plus class="size-4" />
New project
+1 -1
View File
@@ -107,7 +107,7 @@ const projectAreas = [
</Button>
<Button
v-if="$page.props.auth.user.is_admin"
v-if="$page.props.auth.permissions.globalAdmin"
as-child
variant="outline"
>
@@ -61,7 +61,7 @@ const breadcrumbs: BreadcrumbItem[] = [
<div class="flex flex-wrap gap-3">
<Button
v-if="$page.props.auth.user.is_admin"
v-if="$page.props.auth.permissions.globalAdmin"
as-child
variant="outline"
>
@@ -22,10 +22,7 @@ import {
metadata as metadataSettings,
repositories as repositorySettings,
} from '@/routes/projects/settings';
import type {
BreadcrumbItem,
ProjectGeneralSettingsPageProps,
} from '@/types';
import type { BreadcrumbItem, ProjectGeneralSettingsPageProps } from '@/types';
import { computed } from 'vue';
const props = defineProps<ProjectGeneralSettingsPageProps>();
@@ -124,24 +121,24 @@ const submitDelete = (): void => {
</CardHeader>
<CardContent class="grid gap-4 md:grid-cols-2">
<div class="rounded-xl border border-border/70 p-4">
<div class="border-border/70 rounded-xl border p-4">
<p
class="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground"
class="text-muted-foreground text-xs font-semibold uppercase tracking-[0.18em]"
>
Project name
</p>
<p class="mt-2 text-sm font-semibold text-foreground">
<p class="text-foreground mt-2 text-sm font-semibold">
{{ project.name }}
</p>
</div>
<div class="rounded-xl border border-border/70 p-4">
<div class="border-border/70 rounded-xl border p-4">
<p
class="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground"
class="text-muted-foreground text-xs font-semibold uppercase tracking-[0.18em]"
>
Project key
</p>
<p class="mt-2 text-sm font-semibold text-foreground">
<p class="text-foreground mt-2 text-sm font-semibold">
{{ project.key }}
</p>
</div>
@@ -166,9 +163,9 @@ const submitDelete = (): void => {
<CardContent class="space-y-5">
<div
class="rounded-xl border border-red-200/80 bg-background/80 p-4 dark:border-red-900/60"
class="bg-background/80 rounded-xl border border-red-200/80 p-4 dark:border-red-900/60"
>
<p class="text-sm font-medium text-foreground">
<p class="text-foreground text-sm font-medium">
Confirm by typing the exact project key:
<span class="font-semibold">{{ project.key }}</span>
</p>
@@ -193,7 +190,7 @@ const submitDelete = (): void => {
<div
class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
>
<p class="text-sm text-muted-foreground">
<p class="text-muted-foreground text-sm">
This action cannot be undone.
</p>
@@ -65,7 +65,7 @@ const breadcrumbs: BreadcrumbItem[] = [
<div class="flex flex-wrap gap-3">
<Button
v-if="$page.props.auth.user.is_admin"
v-if="$page.props.auth.permissions.globalAdmin"
as-child
variant="outline"
>
@@ -234,7 +234,7 @@ const submitUpdate = (): void => {
<div class="flex flex-wrap gap-3">
<Button
v-if="$page.props.auth.user.is_admin"
v-if="$page.props.auth.permissions.globalAdmin"
as-child
variant="outline"
>
+185 -8
View File
@@ -16,10 +16,10 @@ import {
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import AppLayout from '@/layouts/AppLayout.vue';
import { index as teamsIndex } from '@/routes/admin/teams';
import { index as teamsIndex } from '@/routes/teams';
import type { BreadcrumbItem, TeamsIndexPageProps } from '@/types';
defineProps<TeamsIndexPageProps>();
const props = defineProps<TeamsIndexPageProps>();
const breadcrumbs: BreadcrumbItem[] = [
{
@@ -27,6 +27,15 @@ const breadcrumbs: BreadcrumbItem[] = [
href: teamsIndex(),
},
];
const hasPermission = (
permissions: Record<string, string[]>,
actorId: number,
permission: string,
) => (permissions[actorId] ?? []).includes(permission);
const permissionsForTeam = (teamId: number): Record<string, string[]> =>
props.teamPermissions[teamId] ?? {};
</script>
<template>
@@ -39,12 +48,12 @@ const breadcrumbs: BreadcrumbItem[] = [
>
<Heading
title="Teams"
description="Create reusable teams and manage member assignments for inherited project permissions."
description="Create reusable teams, manage memberships, and assign team-scoped permissions to users."
/>
<Badge variant="outline" class="gap-1.5 px-3 py-1">
<Shield class="size-3.5" />
Admin-only area
{{ canCreateTeams ? 'Global admin' : 'Team-scoped access' }}
</Badge>
</div>
@@ -55,8 +64,15 @@ const breadcrumbs: BreadcrumbItem[] = [
{{ status }}
</div>
<div class="grid gap-6 xl:grid-cols-[22rem_minmax(0,1fr)]">
<Card>
<div
class="grid gap-6"
:class="
canCreateTeams
? 'xl:grid-cols-[22rem_minmax(0,1fr)]'
: 'xl:grid-cols-1'
"
>
<Card v-if="canCreateTeams">
<CardHeader>
<CardTitle>Create team</CardTitle>
<CardDescription>
@@ -121,7 +137,7 @@ const breadcrumbs: BreadcrumbItem[] = [
</div>
</CardHeader>
<CardContent class="pt-6">
<CardContent class="space-y-6 pt-6">
<Form
v-bind="
TeamController.updateMembers.form(team.id)
@@ -164,12 +180,173 @@ const breadcrumbs: BreadcrumbItem[] = [
Save members
</Button>
</Form>
<div class="space-y-4 border-t pt-6">
<div class="space-y-1">
<h3 class="text-foreground font-medium">
Team permissions
</h3>
<p class="text-muted-foreground text-sm">
Direct user permissions on this team are
stored separately from team membership.
</p>
</div>
<div class="overflow-x-auto">
<table
class="divide-border min-w-full divide-y text-sm"
>
<thead
class="bg-muted/40 text-muted-foreground text-left"
>
<tr>
<th
class="px-4 py-3 font-medium"
>
User
</th>
<th
class="px-4 py-3 font-medium"
>
Current permissions
</th>
<th
class="px-4 py-3 text-right font-medium"
>
Action
</th>
</tr>
</thead>
<tbody class="divide-border divide-y">
<tr
v-for="user in users"
:key="`${team.id}-${user.id}`"
class="bg-background align-top"
>
<td class="px-4 py-4">
<div
class="text-foreground font-medium"
>
{{ user.name }}
</div>
<div
class="text-muted-foreground text-xs"
>
{{ user.email }}
</div>
</td>
<td class="px-4 py-4">
<div
class="flex flex-wrap gap-2"
>
<Badge
v-for="option in permissionOptions"
v-show="
hasPermission(
permissionsForTeam(
team.id,
),
user.id,
option.value,
)
"
:key="`${team.id}-${user.id}-${option.value}-badge`"
variant="secondary"
>
{{ option.label }}
</Badge>
<span
v-if="
(
permissionsForTeam(
team.id,
)[
user.id
] ?? []
).length === 0
"
class="text-muted-foreground"
>
No direct team
permissions
</span>
</div>
</td>
<td class="px-4 py-4">
<div
class="flex justify-end"
>
<Form
v-bind="
TeamController.updateUserPermissions.form(
[
team.id,
user.id,
],
)
"
class="flex flex-wrap justify-end gap-3"
v-slot="{
processing,
}"
>
<label
v-for="option in permissionOptions"
:key="`${team.id}-${user.id}-${option.value}`"
class="border-border inline-flex items-center gap-2 rounded-lg border px-3 py-2 text-sm"
>
<input
type="checkbox"
name="permissions[]"
:value="
option.value
"
:checked="
hasPermission(
permissionsForTeam(
team.id,
),
user.id,
option.value,
)
"
class="border-input text-primary focus:ring-ring size-4 rounded"
/>
<span>
{{
option.label
}}
</span>
</label>
<div
class="w-full sm:hidden"
/>
<Button
type="submit"
size="sm"
variant="outline"
:disabled="
processing
"
>
Save
</Button>
</Form>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</CardContent>
</Card>
<Card v-if="teams.length === 0">
<CardContent class="text-muted-foreground p-6 text-sm">
No teams exist yet.
No teams are available for your current permissions.
</CardContent>
</Card>
</div>
+7
View File
@@ -2,7 +2,14 @@ import type { UserModel } from '@/types/models/user-model';
export type User = UserModel;
export type AuthPermissions = {
canManageTeams: boolean;
globalAdmin: boolean;
projectCreate: boolean;
};
export type Auth = {
permissions: AuthPermissions;
user: User;
};
-1
View File
@@ -4,7 +4,6 @@ export type UserModel = {
email: string;
avatar?: string;
email_verified_at: string | null;
is_admin: boolean;
created_at: string;
updated_at: string;
};
+7 -2
View File
@@ -17,6 +17,8 @@ import type { UserGitConnectionModel } from '@/types/models/user-git-connection-
import type { UserModel } from '@/types/models/user-model';
export type AdminUsersPageProps = {
directPermissions: Record<string, string[]>;
permissionOptions: PermissionOption[];
users: UserModel[];
status?: string | null;
};
@@ -31,14 +33,14 @@ export type ProjectShowPageProps = {
project: ProjectModel;
};
export type ProjectPermissionOption = {
export type PermissionOption = {
label: string;
value: string;
};
export type ProjectAccessPageProps = {
directPermissions: Record<string, string[]>;
permissionOptions: ProjectPermissionOption[];
permissionOptions: PermissionOption[];
project: ProjectModel;
status?: string | null;
teamPermissions: Record<string, string[]>;
@@ -47,7 +49,10 @@ export type ProjectAccessPageProps = {
};
export type TeamsIndexPageProps = {
canCreateTeams: boolean;
permissionOptions: PermissionOption[];
status?: string | null;
teamPermissions: Record<string, Record<string, string[]>>;
teams: TeamModel[];
users: UserModel[];
};
+12 -5
View File
@@ -103,6 +103,17 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::delete('projects/{project}/settings/metadata/link-types/{ticketLinkType}', [ProjectTicketMetadataSettingsController::class, 'destroyLinkType'])
->name('projects.settings.metadata.link-types.destroy');
});
Route::get('teams', [TeamController::class, 'index'])
->middleware('can:view-teams')
->name('teams.index');
Route::post('teams', [TeamController::class, 'store'])
->middleware('can:create-team')
->name('teams.store');
Route::patch('teams/{team}/members', [TeamController::class, 'updateMembers'])
->name('teams.members.update');
Route::patch('teams/{team}/permissions/users/{user}', [TeamController::class, 'updateUserPermissions'])
->name('teams.permissions.users.update');
});
Route::middleware(['auth', 'verified', 'can:access-admin'])
@@ -110,11 +121,7 @@ Route::middleware(['auth', 'verified', 'can:access-admin'])
->name('admin.')
->group(function () {
Route::get('users', [UserManagementController::class, 'index'])->name('users.index');
Route::patch('users/{user}/admin-status', [UserManagementController::class, 'update'])->name('users.update');
Route::get('teams', [TeamController::class, 'index'])->name('teams.index');
Route::post('teams', [TeamController::class, 'store'])->name('teams.store');
Route::patch('teams/{team}/members', [TeamController::class, 'updateMembers'])->name('teams.members.update');
Route::patch('users/{user}/permissions', [UserManagementController::class, 'update'])->name('users.update');
});
require __DIR__.'/settings.php';
@@ -27,10 +27,10 @@ class UserManagementControllerTest extends TestCase
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('admin/users/Index')
->where('users.0.email', $admin->email)
->where('users.1.email', $member->email)
->where('users.0.is_admin', true)
->where('users.1.is_admin', false),
->has('users', 2)
->where("directPermissions.{$admin->id}.0", 'global:admin')
->where('permissionOptions.0.value', 'global:admin')
->where('permissionOptions.1.value', 'project:create'),
);
}
@@ -47,9 +47,9 @@ class UserManagementControllerTest extends TestCase
}
/**
* Ensure admins can update another user's admin access.
* Ensure admins can update another user's global permissions.
*/
public function test_admin_can_update_a_users_admin_access(): void
public function test_admin_can_update_a_users_global_permissions(): void
{
$admin = User::factory()->admin()->create();
$member = User::factory()->create([
@@ -58,30 +58,72 @@ class UserManagementControllerTest extends TestCase
$this->actingAs($admin)
->patch(route('admin.users.update', $member), [
'is_admin' => true,
'permissions' => ['global:admin', 'project:create'],
])
->assertRedirect(route('admin.users.index'))
->assertSessionHas('status', 'Regular User now has admin access.');
->assertSessionHas('status', 'Regular User global permissions were updated.');
$this->assertTrue($member->refresh()->is_admin);
$this->assertDatabaseHas('permission_assignments', [
'assignee_id' => $member->id,
'assignee_type' => $member->getMorphClass(),
'permission' => 'global:admin',
'scope_id' => null,
'scope_type' => null,
]);
$this->assertDatabaseHas('permission_assignments', [
'assignee_id' => $member->id,
'assignee_type' => $member->getMorphClass(),
'permission' => 'project:create',
'scope_id' => null,
'scope_type' => null,
]);
}
/**
* Ensure non-admin users cannot update admin access.
* Ensure admins can also clear a user's global permissions.
*/
public function test_non_admin_cannot_update_a_users_admin_access(): void
public function test_admin_can_clear_a_users_global_permissions(): void
{
$admin = User::factory()->admin()->create();
$member = User::factory()->create();
$this->syncGlobalPermissions($member, ['global:admin', 'project:create']);
$this->actingAs($admin)
->patch(route('admin.users.update', $member), [
'permissions' => [],
])
->assertRedirect(route('admin.users.index'))
->assertSessionHas('status', "{$member->name} global permissions were updated.");
$this->assertDatabaseMissing('permission_assignments', [
'assignee_id' => $member->id,
'assignee_type' => $member->getMorphClass(),
'scope_id' => null,
'scope_type' => null,
]);
}
/**
* Ensure non-admin users cannot update global permissions.
*/
public function test_non_admin_cannot_update_a_users_global_permissions(): void
{
$member = User::factory()->create();
$otherUser = User::factory()->create([
'is_admin' => false,
]);
$otherUser = User::factory()->create();
$this->actingAs($member)
->patch(route('admin.users.update', $otherUser), [
'is_admin' => true,
'permissions' => ['global:admin'],
])
->assertForbidden();
$this->assertFalse($otherUser->refresh()->is_admin);
$this->assertDatabaseMissing('permission_assignments', [
'assignee_id' => $otherUser->id,
'assignee_type' => $otherUser->getMorphClass(),
'permission' => 'global:admin',
'scope_id' => null,
'scope_type' => null,
]);
}
}
@@ -0,0 +1,66 @@
<?php
namespace Tests\Feature\Models;
use App\Models\PermissionAssignment;
use App\Models\Project;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PermissionAssignmentTest extends TestCase
{
use RefreshDatabase;
/**
* Ensure the permission assignment factory creates a user-to-project assignment.
*/
public function test_permission_assignment_factory_creates_a_project_assignment(): void
{
$assignment = PermissionAssignment::factory()->create();
$this->assertInstanceOf(User::class, $assignment->assignee);
$this->assertInstanceOf(Project::class, $assignment->scope);
$this->assertSame('project:read', $assignment->permission->value);
}
/**
* Ensure deleting users, teams, and projects removes related permission assignments.
*/
public function test_related_permission_assignments_are_removed_when_parent_models_are_deleted(): void
{
$user = User::factory()->create();
$team = Team::factory()->create();
$project = Project::factory()->create();
$userAssignment = PermissionAssignment::factory()
->forAssignee($user)
->globalAdmin()
->create();
$teamAssignment = PermissionAssignment::factory()
->forAssignee($team)
->forScope($project)
->projectWrite()
->create();
$teamScopeAssignment = PermissionAssignment::factory()
->forAssignee($user)
->forScope($team)
->teamManage()
->create();
$user->delete();
$project->delete();
$team->delete();
$this->assertDatabaseMissing('permission_assignments', [
'id' => $userAssignment->id,
]);
$this->assertDatabaseMissing('permission_assignments', [
'id' => $teamAssignment->id,
]);
$this->assertDatabaseMissing('permission_assignments', [
'id' => $teamScopeAssignment->id,
]);
}
}
+13 -9
View File
@@ -66,20 +66,24 @@ class TeamTest extends TestCase
ProjectAccessSeeder::class,
]);
$admin = User::query()->where('email', 'admin@kanbangit.test')->firstOrFail();
$coreProject = Project::query()->where('key', 'CORE')->firstOrFail();
$platformTeam = Team::query()->where('name', 'Platform Team')->firstOrFail();
$testUser = User::query()->where('email', 'test@example.com')->firstOrFail();
$this->assertDatabaseHas('project_user_permissions', [
'project_id' => $coreProject->id,
'user_id' => $admin->id,
'permission' => 'view',
$this->assertDatabaseHas('permission_assignments', [
'assignee_id' => $platformTeam->id,
'assignee_type' => $platformTeam->getMorphClass(),
'permission' => 'project:write',
'scope_id' => $coreProject->id,
'scope_type' => $coreProject->getMorphClass(),
]);
$this->assertDatabaseHas('project_team_permissions', [
'project_id' => $coreProject->id,
'team_id' => $platformTeam->id,
'permission' => 'edit',
$this->assertDatabaseHas('permission_assignments', [
'assignee_id' => $testUser->id,
'assignee_type' => $testUser->getMorphClass(),
'permission' => 'team:manage',
'scope_id' => $platformTeam->id,
'scope_type' => $platformTeam->getMorphClass(),
]);
}
}
+31 -10
View File
@@ -13,13 +13,19 @@ class UserTest extends TestCase
use RefreshDatabase;
/**
* Ensure the default user factory creates non-admin users.
* Ensure the default user factory does not grant global admin access.
*/
public function test_user_factory_defaults_to_non_admin(): void
public function test_user_factory_defaults_to_no_global_admin_access(): void
{
$user = User::factory()->create();
$this->assertFalse($user->is_admin);
$this->assertDatabaseMissing('permission_assignments', [
'assignee_id' => $user->id,
'assignee_type' => $user->getMorphClass(),
'permission' => 'global:admin',
'scope_id' => null,
'scope_type' => null,
]);
}
/**
@@ -29,7 +35,13 @@ class UserTest extends TestCase
{
$user = User::factory()->admin()->create();
$this->assertTrue($user->is_admin);
$this->assertDatabaseHas('permission_assignments', [
'assignee_id' => $user->id,
'assignee_type' => $user->getMorphClass(),
'permission' => 'global:admin',
'scope_id' => null,
'scope_type' => null,
]);
}
/**
@@ -39,14 +51,23 @@ class UserTest extends TestCase
{
$this->seed(UserSeeder::class);
$this->assertDatabaseHas('users', [
'email' => 'admin@kanbangit.test',
'is_admin' => true,
$admin = User::query()->where('email', 'admin@kanbangit.test')->firstOrFail();
$member = User::query()->where('email', 'test@example.com')->firstOrFail();
$this->assertDatabaseHas('permission_assignments', [
'assignee_id' => $admin->id,
'assignee_type' => $admin->getMorphClass(),
'permission' => 'global:admin',
'scope_id' => null,
'scope_type' => null,
]);
$this->assertDatabaseHas('users', [
'email' => 'test@example.com',
'is_admin' => false,
$this->assertDatabaseHas('permission_assignments', [
'assignee_id' => $member->id,
'assignee_type' => $member->getMorphClass(),
'permission' => 'project:create',
'scope_id' => null,
'scope_type' => null,
]);
}
@@ -0,0 +1,119 @@
<?php
namespace Tests\Feature\Permissions;
use App\Actions\Permissions\PermissionService;
use App\Enums\Permission;
use App\Models\Project;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class PermissionServiceTest extends TestCase
{
use RefreshDatabase;
/**
* Ensure direct project permissions expand through the configured hierarchy.
*/
public function test_direct_project_permissions_expand_through_hierarchy(): void
{
$service = app(PermissionService::class);
$user = User::factory()->create();
$project = Project::factory()->create();
$service->sync($user, $project, [Permission::ProjectWrite]);
$this->assertTrue($service->allows($user, Permission::ProjectRead, $project));
$this->assertTrue($service->allows($user, Permission::ProjectWrite, $project));
$this->assertFalse($service->allows($user, Permission::ProjectAdmin, $project));
}
/**
* Ensure project permissions assigned to teams are inherited by team members.
*/
public function test_team_project_permissions_are_inherited_by_members(): void
{
$service = app(PermissionService::class);
$user = User::factory()->create();
$team = Team::factory()->create();
$project = Project::factory()->create();
$team->users()->attach($user);
$service->sync($team, $project, [Permission::ProjectRead]);
$this->assertTrue($service->allows($user, Permission::ProjectRead, $project));
}
/**
* Ensure user and team permissions are combined instead of one replacing the other.
*/
public function test_direct_user_permissions_are_combined_with_inherited_team_permissions(): void
{
$service = app(PermissionService::class);
$user = User::factory()->create();
$team = Team::factory()->create();
$project = Project::factory()->create();
$team->users()->attach($user);
$service->sync($team, $project, [Permission::ProjectWrite]);
$service->sync($user, $project, [Permission::ProjectRead]);
$this->assertTrue($service->allows($user, Permission::ProjectWrite, $project));
$this->assertTrue($service->allows($user, Permission::ProjectRead, $project));
}
/**
* Ensure team-scoped permissions are granted directly to users and do not come from membership alone.
*/
public function test_team_permissions_only_apply_when_directly_assigned_to_the_user(): void
{
$service = app(PermissionService::class);
$manager = User::factory()->create();
$member = User::factory()->create();
$team = Team::factory()->create();
$team->users()->attach([$manager->id, $member->id]);
$service->sync($manager, $team, [Permission::TeamManage]);
$this->assertTrue($service->allows($manager, Permission::TeamManage, $team));
$this->assertFalse($service->allows($member, Permission::TeamManage, $team));
}
/**
* Ensure global admins can perform global, project, and team-scoped actions.
*/
public function test_global_admin_overrides_every_scope(): void
{
$service = app(PermissionService::class);
$admin = User::factory()->create();
$project = Project::factory()->create();
$team = Team::factory()->create();
$service->sync($admin, null, [Permission::GlobalAdmin]);
$this->assertTrue($service->allows($admin, Permission::ProjectCreate));
$this->assertTrue($service->allows($admin, Permission::ProjectAdmin, $project));
$this->assertTrue($service->allows($admin, Permission::TeamManage, $team));
}
/**
* Ensure the service can surface accessible project ids and manageable team ids.
*/
public function test_scope_lookup_helpers_return_expected_ids(): void
{
$service = app(PermissionService::class);
$user = User::factory()->create();
$team = Team::factory()->create();
$project = Project::factory()->create();
$team->users()->attach($user);
$service->sync($team, $project, [Permission::ProjectRead]);
$service->sync($user, $team, [Permission::TeamManage]);
$this->assertSame([$project->id], $service->viewableProjectIds($user));
$this->assertSame([$team->id], $service->manageableTeamIds($user));
$this->assertTrue($service->canManageAnyTeam($user));
}
}
@@ -2,7 +2,6 @@
namespace Tests\Feature\Projects;
use App\Actions\ProjectPermissions\ProjectPermissionService;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\BoardColumn;
use App\Models\Project;
@@ -47,7 +46,7 @@ class ProjectBacklogControllerTest extends TestCase
'title' => 'Seed backlog item',
]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['view']);
$this->syncProjectPermissions($project, $user, ['view']);
$this->actingAs($user)
->get(route('projects.backlog.index', $project))
@@ -77,7 +76,7 @@ class ProjectBacklogControllerTest extends TestCase
'position' => 1,
]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['view']);
$this->syncProjectPermissions($project, $user, ['view']);
$this->actingAs($user)
->post(route('projects.backlog.store', $project), [
@@ -107,7 +106,7 @@ class ProjectBacklogControllerTest extends TestCase
'position' => 1,
]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['create', 'view']);
$this->syncProjectPermissions($project, $user, ['create', 'view']);
$payload = [
'title' => 'Same title',
@@ -2,7 +2,6 @@
namespace Tests\Feature\Projects;
use App\Actions\ProjectPermissions\ProjectPermissionService;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\BoardColumn;
use App\Models\Project;
@@ -63,7 +62,7 @@ class ProjectBoardControllerTest extends TestCase
'title' => 'Board ticket',
]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['view']);
$this->syncProjectPermissions($project, $user, ['view']);
$this->actingAs($user)
->get(route('projects.board.index', $project))
@@ -113,7 +112,7 @@ class ProjectBoardControllerTest extends TestCase
'title' => 'Backlog ticket',
]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit', 'view']);
$this->syncProjectPermissions($project, $user, ['edit', 'view']);
$this->actingAs($user)
->patch(route('projects.tickets.status.update', [$project, $ticket]), [
@@ -172,7 +171,7 @@ class ProjectBoardControllerTest extends TestCase
'title' => 'Board ticket',
]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit', 'view']);
$this->syncProjectPermissions($project, $user, ['edit', 'view']);
$this->actingAs($user)
->patch(route('projects.tickets.status.update', [$project, $ticket]), [
@@ -227,7 +226,7 @@ class ProjectBoardControllerTest extends TestCase
'title' => 'Board ticket',
]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit', 'view']);
$this->syncProjectPermissions($project, $user, ['edit', 'view']);
$this->actingAs($user)
->patch(route('projects.tickets.status.update', [$project, $ticket]), [
@@ -280,7 +279,7 @@ class ProjectBoardControllerTest extends TestCase
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit', 'view']);
$this->syncProjectPermissions($project, $user, ['edit', 'view']);
$this->actingAs($user)
->from(route('projects.backlog.index', $project))
@@ -2,7 +2,6 @@
namespace Tests\Feature\Projects;
use App\Actions\ProjectPermissions\ProjectPermissionService;
use App\Models\BoardColumn;
use App\Models\Project;
use App\Models\User;
@@ -28,7 +27,7 @@ class ProjectBoardSettingsControllerTest extends TestCase
'position' => 1,
]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
$this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user)
->get(route('projects.settings.board', $project))
@@ -63,7 +62,7 @@ class ProjectBoardSettingsControllerTest extends TestCase
'key' => 'CORE',
]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
$this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user)
->post(route('projects.settings.board.columns.store', $project), [
@@ -99,7 +98,7 @@ class ProjectBoardSettingsControllerTest extends TestCase
'position' => 3,
]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
$this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user)
->patch(route('projects.settings.board.columns.update', [$project, $thirdColumn]), [
@@ -139,7 +138,7 @@ class ProjectBoardSettingsControllerTest extends TestCase
'position' => 3,
]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
$this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user)
->delete(route('projects.settings.board.columns.destroy', [$project, $secondColumn]))
@@ -2,7 +2,6 @@
namespace Tests\Feature\Projects;
use App\Actions\ProjectPermissions\ProjectPermissionService;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\BoardColumn;
use App\Models\Project;
@@ -80,9 +79,8 @@ class ProjectControllerTest extends TestCase
'key' => 'HIDE',
]);
$projectPermissionService = app(ProjectPermissionService::class);
$projectPermissionService->syncUserPermissions($directProject, $user, ['view']);
$projectPermissionService->syncTeamPermissions($teamProject, $team, ['view']);
$this->syncProjectPermissions($directProject, $user, ['view']);
$this->syncProjectPermissions($teamProject, $team, ['view']);
$this->actingAs($user)
->get(route('projects.index'))
@@ -96,11 +94,12 @@ class ProjectControllerTest extends TestCase
}
/**
* Ensure authenticated users can view the project creation screen.
* Ensure users with project create permission can view the project creation screen.
*/
public function test_authenticated_users_can_view_the_project_creation_screen(): void
{
$user = User::factory()->create();
$this->syncGlobalPermissions($user, ['project:create']);
$this->actingAs($user)
->get(route('projects.create'))
@@ -111,11 +110,24 @@ class ProjectControllerTest extends TestCase
}
/**
* Ensure project keys are normalized to uppercase on creation.
* Ensure users without project create permission cannot view the project creation screen.
*/
public function test_users_without_project_create_permission_cannot_view_the_project_creation_screen(): void
{
$user = User::factory()->create();
$this->actingAs($user)
->get(route('projects.create'))
->assertForbidden();
}
/**
* Ensure users with project create permission can create projects with uppercase keys.
*/
public function test_authenticated_users_can_create_projects_with_uppercase_keys(): void
{
$user = User::factory()->create();
$this->syncGlobalPermissions($user, ['project:create']);
$response = $this->actingAs($user)
->post(route('projects.store'), [
@@ -132,13 +144,13 @@ class ProjectControllerTest extends TestCase
'key' => 'CORE',
]);
foreach (ProjectPermissionService::permissions() as $permission) {
$this->assertDatabaseHas('project_user_permissions', [
'project_id' => $project->id,
'user_id' => $user->id,
'permission' => $permission,
]);
}
$this->assertDatabaseHas('permission_assignments', [
'assignee_id' => $user->id,
'assignee_type' => $user->getMorphClass(),
'permission' => 'project:admin',
'scope_id' => $project->id,
'scope_type' => $project->getMorphClass(),
]);
$this->assertDatabaseHas('ticket_types', [
'project_id' => $project->id,
@@ -161,6 +173,7 @@ class ProjectControllerTest extends TestCase
public function test_project_keys_must_be_unique_case_insensitively(): void
{
$user = User::factory()->create();
$this->syncGlobalPermissions($user, ['project:create']);
Project::factory()->create([
'key' => 'AUTH',
@@ -182,6 +195,7 @@ class ProjectControllerTest extends TestCase
public function test_project_keys_may_not_exceed_five_characters(): void
{
$user = User::factory()->create();
$this->syncGlobalPermissions($user, ['project:create']);
$this->actingAs($user)
->from(route('projects.create'))
@@ -203,7 +217,7 @@ class ProjectControllerTest extends TestCase
'name' => 'Core Platform',
'key' => 'CORE',
]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['view']);
$this->syncProjectPermissions($project, $user, ['view']);
$this->actingAs($user)
->get(route('projects.show', $project))
@@ -228,7 +242,7 @@ class ProjectControllerTest extends TestCase
]);
$team->users()->attach($user);
app(ProjectPermissionService::class)->syncTeamPermissions($project, $team, ['view']);
$this->syncProjectPermissions($project, $team, ['view']);
$this->actingAs($user)
->get(route('projects.show', $project))
@@ -253,9 +267,9 @@ class ProjectControllerTest extends TestCase
}
/**
* Ensure edit access allows opening the project access management page.
* Ensure project admin access allows opening the project access management page.
*/
public function test_users_with_edit_permission_can_view_project_access_management(): void
public function test_users_with_project_admin_permission_can_view_project_access_management(): void
{
$user = User::factory()->create();
$project = Project::factory()->create([
@@ -265,7 +279,7 @@ class ProjectControllerTest extends TestCase
'name' => 'Platform Team',
]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
$this->syncProjectPermissions($project, $user, ['admin']);
$this->actingAs($user)
->get(route('projects.access', $project))
@@ -275,7 +289,7 @@ class ProjectControllerTest extends TestCase
->where('project.id', $project->id)
->has('users', 1)
->has('teams', 1)
->where('permissionOptions.0.value', 'create'),
->where('permissionOptions.0.value', 'project:read'),
);
}
@@ -309,7 +323,7 @@ class ProjectControllerTest extends TestCase
'key' => 'CORE',
]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['view', 'edit', 'delete']);
$this->syncProjectPermissions($project, $user, ['admin']);
$this->actingAs($user)
->get(route('projects.settings.general', $project))
@@ -348,7 +362,7 @@ class ProjectControllerTest extends TestCase
'title' => 'Delete me',
]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $member, ['view']);
$this->syncProjectPermissions($project, $member, ['view']);
$this->actingAs($admin)
->delete(route('projects.destroy', $project), [
@@ -366,10 +380,12 @@ class ProjectControllerTest extends TestCase
$this->assertDatabaseMissing('tickets', [
'id' => $ticket->id,
]);
$this->assertDatabaseMissing('project_user_permissions', [
'project_id' => $project->id,
'user_id' => $member->id,
'permission' => 'view',
$this->assertDatabaseMissing('permission_assignments', [
'assignee_id' => $member->id,
'assignee_type' => $member->getMorphClass(),
'permission' => 'project:read',
'scope_id' => $project->id,
'scope_type' => $project->getMorphClass(),
]);
}
@@ -406,7 +422,7 @@ class ProjectControllerTest extends TestCase
'key' => 'CORE',
]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['view', 'edit', 'delete']);
$this->syncProjectPermissions($project, $user, ['admin']);
$this->actingAs($user)
->delete(route('projects.destroy', $project), [
@@ -434,28 +450,32 @@ class ProjectControllerTest extends TestCase
$this->actingAs($admin)
->patch(route('projects.permissions.users.update', [$project, $member]), [
'permissions' => ['view', 'edit'],
'permissions' => ['project:read', 'project:write'],
])
->assertRedirect(route('projects.access', $project))
->assertSessionHas('status', 'Regular User permissions were updated.');
$this->assertDatabaseHas('project_user_permissions', [
'project_id' => $project->id,
'user_id' => $member->id,
'permission' => 'view',
$this->assertDatabaseHas('permission_assignments', [
'assignee_id' => $member->id,
'assignee_type' => $member->getMorphClass(),
'permission' => 'project:read',
'scope_id' => $project->id,
'scope_type' => $project->getMorphClass(),
]);
$this->assertDatabaseHas('project_user_permissions', [
'project_id' => $project->id,
'user_id' => $member->id,
'permission' => 'edit',
$this->assertDatabaseHas('permission_assignments', [
'assignee_id' => $member->id,
'assignee_type' => $member->getMorphClass(),
'permission' => 'project:write',
'scope_id' => $project->id,
'scope_type' => $project->getMorphClass(),
]);
}
/**
* Ensure users with project edit access can update team permissions.
* Ensure users with project admin access can update team permissions.
*/
public function test_users_with_edit_permission_can_update_team_permissions(): void
public function test_users_with_project_admin_permission_can_update_team_permissions(): void
{
$manager = User::factory()->create();
$team = Team::factory()->create([
@@ -465,25 +485,29 @@ class ProjectControllerTest extends TestCase
'key' => 'OPS',
]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $manager, ['edit']);
$this->syncProjectPermissions($project, $manager, ['admin']);
$this->actingAs($manager)
->patch(route('projects.permissions.teams.update', [$project, $team]), [
'permissions' => ['view', 'create'],
'permissions' => ['project:read', 'project:write'],
])
->assertRedirect(route('projects.access', $project))
->assertSessionHas('status', 'Delivery Team permissions were updated.');
$this->assertDatabaseHas('project_team_permissions', [
'project_id' => $project->id,
'team_id' => $team->id,
'permission' => 'view',
$this->assertDatabaseHas('permission_assignments', [
'assignee_id' => $team->id,
'assignee_type' => $team->getMorphClass(),
'permission' => 'project:read',
'scope_id' => $project->id,
'scope_type' => $project->getMorphClass(),
]);
$this->assertDatabaseHas('project_team_permissions', [
'project_id' => $project->id,
'team_id' => $team->id,
'permission' => 'create',
$this->assertDatabaseHas('permission_assignments', [
'assignee_id' => $team->id,
'assignee_type' => $team->getMorphClass(),
'permission' => 'project:write',
'scope_id' => $project->id,
'scope_type' => $project->getMorphClass(),
]);
}
}
@@ -3,7 +3,6 @@
namespace Tests\Feature\Projects;
use App\Actions\Git\GitProviderKey;
use App\Actions\ProjectPermissions\ProjectPermissionService;
use App\Models\Project;
use App\Models\RepositoryConnection;
use App\Models\User;
@@ -37,7 +36,7 @@ class ProjectRepositorySettingsControllerTest extends TestCase
'repository_url' => 'https://git.example.test/core-team/kanbangit',
]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
$this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user)
->get(route('projects.settings.repositories', $project))
@@ -92,7 +91,7 @@ class ProjectRepositorySettingsControllerTest extends TestCase
'provider' => GitProviderKey::Gitea,
]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
$this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user)
->post(route('projects.settings.repositories.store', $project), [
@@ -126,7 +125,7 @@ class ProjectRepositorySettingsControllerTest extends TestCase
$project = Project::factory()->create();
$foreignGitConnection = UserGitConnection::factory()->for($otherUser)->create();
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
$this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user)
->post(route('projects.settings.repositories.store', $project), [
@@ -173,7 +172,7 @@ class ProjectRepositorySettingsControllerTest extends TestCase
'provider' => GitProviderKey::Gitea,
]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
$this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user)
->patch(route('projects.settings.repositories.update', [$project, $repositoryConnection]), [
@@ -204,7 +203,7 @@ class ProjectRepositorySettingsControllerTest extends TestCase
'repository_full_name' => 'core-team/kanbangit',
]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
$this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user)
->delete(route('projects.settings.repositories.destroy', [$project, $repositoryConnection]))
@@ -3,7 +3,6 @@
namespace Tests\Feature\Projects;
use App\Actions\Git\GitProviderKey;
use App\Actions\ProjectPermissions\ProjectPermissionService;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\Project;
use App\Models\RepositoryConnection;
@@ -48,7 +47,7 @@ class ProjectTicketBranchControllerTest extends TestCase
'provider' => GitProviderKey::Gitea,
]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit', 'view']);
$this->syncProjectPermissions($project, $user, ['edit', 'view']);
$this->actingAs($user)
->getJson(route('projects.tickets.branches.validate', [
@@ -92,7 +91,7 @@ class ProjectTicketBranchControllerTest extends TestCase
'provider' => GitProviderKey::Gitea,
]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit', 'view']);
$this->syncProjectPermissions($project, $user, ['edit', 'view']);
$this->actingAs($user)
->getJson(route('projects.tickets.branches.validate', [
@@ -128,7 +127,7 @@ class ProjectTicketBranchControllerTest extends TestCase
'repository_url' => 'https://git.example.test/core-team/kanbangit',
]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit', 'view']);
$this->syncProjectPermissions($project, $user, ['edit', 'view']);
$this->actingAs($user)
->getJson(route('projects.tickets.branches.validate', [
@@ -188,7 +187,7 @@ class ProjectTicketBranchControllerTest extends TestCase
'provider' => GitProviderKey::Gitea,
]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit', 'view']);
$this->syncProjectPermissions($project, $user, ['edit', 'view']);
$this->actingAs($user)
->post(route('projects.tickets.branches.store', [$project, $ticket]), [
@@ -2,7 +2,6 @@
namespace Tests\Feature\Projects;
use App\Actions\ProjectPermissions\ProjectPermissionService;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\Project;
use App\Models\RepositoryConnection;
@@ -86,7 +85,7 @@ class ProjectTicketControllerTest extends TestCase
'ticket_id' => $ticket->id,
]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $viewer, ['view']);
$this->syncProjectPermissions($project, $viewer, ['view']);
$this->actingAs($viewer)
->get(route('projects.tickets.show', [$project, $ticket]))
@@ -196,7 +195,7 @@ class ProjectTicketControllerTest extends TestCase
'base_url' => 'https://git.example.test',
]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $viewer, ['view']);
$this->syncProjectPermissions($project, $viewer, ['view']);
$this->actingAs($viewer)
->get(route('projects.tickets.show', [$project, $ticket]))
@@ -244,7 +243,7 @@ class ProjectTicketControllerTest extends TestCase
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $viewer, ['view']);
$this->syncProjectPermissions($project, $viewer, ['view']);
$this->actingAs($viewer)
->patch(route('projects.tickets.update', [$project, $ticket]), [
@@ -325,7 +324,7 @@ class ProjectTicketControllerTest extends TestCase
'value_user_id' => null,
]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $editor, ['edit', 'view']);
$this->syncProjectPermissions($project, $editor, ['edit', 'view']);
$this->actingAs($editor)
->patch(route('projects.tickets.update', [$project, $ticket]), [
@@ -417,7 +416,7 @@ class ProjectTicketControllerTest extends TestCase
'title' => 'Child ticket',
]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $editor, ['edit', 'view']);
$this->syncProjectPermissions($project, $editor, ['edit', 'view']);
$this->actingAs($editor)
->from(route('projects.tickets.show', [$project, $childTicket]))
@@ -474,7 +473,7 @@ class ProjectTicketControllerTest extends TestCase
'title' => 'Child ticket',
]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $editor, ['edit', 'view']);
$this->syncProjectPermissions($project, $editor, ['edit', 'view']);
$this->actingAs($editor)
->patch(route('projects.tickets.update', [$project, $childTicket]), [
@@ -563,7 +562,7 @@ class ProjectTicketControllerTest extends TestCase
'title' => 'Nested task',
]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $viewer, ['view']);
$this->syncProjectPermissions($project, $viewer, ['view']);
$this->actingAs($viewer)
->get(route('projects.tickets.show', [$project, $rootParent]))
@@ -596,7 +595,7 @@ class ProjectTicketControllerTest extends TestCase
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $editor, ['edit', 'view']);
$this->syncProjectPermissions($project, $editor, ['edit', 'view']);
$this->actingAs($editor)
->from(route('projects.tickets.show', [$project, $ticket]))
@@ -645,7 +644,7 @@ class ProjectTicketControllerTest extends TestCase
'ticket_type_id' => $project->ticketTypes()->firstOrFail()->id,
]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $editor, ['edit', 'view']);
$this->syncProjectPermissions($project, $editor, ['edit', 'view']);
$this->actingAs($editor)
->patch(route('projects.tickets.update', [$project, $ticket]), [
@@ -2,7 +2,6 @@
namespace Tests\Feature\Projects;
use App\Actions\ProjectPermissions\ProjectPermissionService;
use App\Actions\ProjectTicketMetadata\InitializeProjectTicketMetadata;
use App\Models\Project;
use App\Models\Ticket;
@@ -53,7 +52,7 @@ class ProjectTicketLinkControllerTest extends TestCase
'ticket_type_id' => $taskType->id,
]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit', 'view']);
$this->syncProjectPermissions($project, $user, ['edit', 'view']);
$this->actingAs($user)
->post(route('projects.tickets.links.store', [$project, $ticket]), [
@@ -118,7 +117,7 @@ class ProjectTicketLinkControllerTest extends TestCase
'ticket_type_id' => $otherTaskType->id,
]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit', 'view']);
$this->syncProjectPermissions($project, $user, ['edit', 'view']);
$this->actingAs($user)
->from(route('projects.tickets.show', [$project, $ticket]))
@@ -180,7 +179,7 @@ class ProjectTicketLinkControllerTest extends TestCase
'ticket_link_type_id' => $ticketLinkType->id,
]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit', 'view']);
$this->syncProjectPermissions($project, $user, ['edit', 'view']);
$this->actingAs($user)
->delete(route('projects.tickets.links.destroy', [$project, $ticket, $ticketLink]))
@@ -2,7 +2,6 @@
namespace Tests\Feature\Projects;
use App\Actions\ProjectPermissions\ProjectPermissionService;
use App\Models\Project;
use App\Models\TicketFieldDefinition;
use App\Models\TicketLinkType;
@@ -33,7 +32,7 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
'position' => 1,
]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
$this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user)
->get(route('projects.settings.metadata', $project))
@@ -55,7 +54,7 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
$user = User::factory()->create();
$project = Project::factory()->create();
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
$this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user)
->post(route('projects.settings.metadata.types.store', $project), [
@@ -80,7 +79,7 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
$user = User::factory()->create();
$project = Project::factory()->create();
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
$this->syncProjectPermissions($project, $user, ['edit']);
$taskType = TicketType::query()
->where('project_id', $project->id)
@@ -130,7 +129,7 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
'position' => 1,
]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
$this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user)
->post(route('projects.settings.metadata.priorities.store', $project), [
@@ -169,7 +168,7 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
'position' => 1,
]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
$this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user)
->post(route('projects.settings.metadata.fields.store', $project), [
@@ -203,7 +202,7 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
$user = User::factory()->create();
$project = Project::factory()->create();
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
$this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user)
->from(route('projects.settings.metadata', $project))
@@ -227,7 +226,7 @@ class ProjectTicketMetadataSettingsControllerTest extends TestCase
'position' => 1,
]);
app(ProjectPermissionService::class)->syncUserPermissions($project, $user, ['edit']);
$this->syncProjectPermissions($project, $user, ['edit']);
$this->actingAs($user)
->post(route('projects.settings.metadata.link-types.store', $project), [
+96 -9
View File
@@ -28,10 +28,11 @@ class TeamControllerTest extends TestCase
$team->users()->attach($member);
$this->actingAs($admin)
->get(route('admin.teams.index'))
->get(route('teams.index'))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('teams/Index')
->where('canCreateTeams', true)
->where('teams.0.name', 'Platform Team')
->where('teams.0.user_ids.0', $member->id)
->where('teams.0.users.0.email', 'member@example.com'),
@@ -46,10 +47,10 @@ class TeamControllerTest extends TestCase
$admin = User::factory()->admin()->create();
$this->actingAs($admin)
->post(route('admin.teams.store'), [
->post(route('teams.store'), [
'name' => 'Delivery Team',
])
->assertRedirect(route('admin.teams.index'))
->assertRedirect(route('teams.index'))
->assertSessionHas('status', 'Delivery Team was created.');
$this->assertDatabaseHas('teams', [
@@ -70,10 +71,10 @@ class TeamControllerTest extends TestCase
]);
$this->actingAs($admin)
->patch(route('admin.teams.members.update', $team), [
->patch(route('teams.members.update', $team), [
'user_ids' => [$firstMember->id, $secondMember->id],
])
->assertRedirect(route('admin.teams.index'))
->assertRedirect(route('teams.index'))
->assertSessionHas('status', 'Platform Team members were updated.');
$this->assertDatabaseHas('team_user', [
@@ -88,14 +89,61 @@ class TeamControllerTest extends TestCase
}
/**
* Ensure non-admin users cannot view the teams management page.
* Ensure admins can view the teams management page.
*/
public function test_non_admins_with_team_create_permission_can_view_the_teams_management_page(): void
{
$createTeamUser = User::factory()->create();
$member = User::factory()->create([
'name' => 'Regular User',
'email' => 'member@example.com',
]);
$team = Team::factory()->create([
'name' => 'Platform Team',
]);
$team->users()->attach($member);
$this->syncGlobalPermissions($createTeamUser, ['team:create']);
$this->actingAs($createTeamUser)
->get(route('teams.index'))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('teams/Index')
->where('canCreateTeams', true)
);
}
/**
* Ensure admins can create teams.
*/
public function test_non_admins_with_team_create_permission_can_create_teams(): void
{
$createTeamUser = User::factory()->create();
$this->syncGlobalPermissions($createTeamUser, ['team:create']);
$this->actingAs($createTeamUser)
->post(route('teams.store'), [
'name' => 'Delivery Team',
])
->assertRedirect(route('teams.index'))
->assertSessionHas('status', 'Delivery Team was created.');
$this->assertDatabaseHas('teams', [
'name' => 'Delivery Team',
]);
}
/**
* Ensure users without team management access cannot view the teams page.
*/
public function test_non_admins_cannot_view_the_teams_management_page(): void
{
$user = User::factory()->create();
$this->actingAs($user)
->get(route('admin.teams.index'))
->get(route('teams.index'))
->assertForbidden();
}
@@ -107,7 +155,7 @@ class TeamControllerTest extends TestCase
$user = User::factory()->create();
$this->actingAs($user)
->post(route('admin.teams.store'), [
->post(route('teams.store'), [
'name' => 'Platform Team',
])
->assertForbidden();
@@ -122,9 +170,48 @@ class TeamControllerTest extends TestCase
$team = Team::factory()->create();
$this->actingAs($user)
->patch(route('admin.teams.members.update', $team), [
->patch(route('teams.members.update', $team), [
'user_ids' => [],
])
->assertForbidden();
}
/**
* Ensure users with team manage access can view and update their assigned teams.
*/
public function test_team_managers_can_view_and_update_their_team(): void
{
$manager = User::factory()->create();
$member = User::factory()->create();
$team = Team::factory()->create([
'name' => 'Platform Team',
]);
$this->syncTeamPermissions($team, $manager, ['team:manage']);
$this->actingAs($manager)
->get(route('teams.index'))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('teams/Index')
->where('canCreateTeams', false)
->where('teams.0.name', 'Platform Team')
->where("teamPermissions.{$team->id}.{$manager->id}.0", 'team:manage'),
);
$this->actingAs($manager)
->patch(route('teams.permissions.users.update', [$team, $member]), [
'permissions' => ['team:invite'],
])
->assertRedirect(route('teams.index'))
->assertSessionHas('status', "{$member->name} permissions on {$team->name} were updated.");
$this->assertDatabaseHas('permission_assignments', [
'assignee_id' => $member->id,
'assignee_type' => $member->getMorphClass(),
'permission' => 'team:invite',
'scope_id' => $team->id,
'scope_type' => $team->getMorphClass(),
]);
}
}
+80
View File
@@ -2,6 +2,11 @@
namespace Tests;
use App\Actions\Permissions\PermissionService;
use App\Enums\Permission;
use App\Models\Project;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Laravel\Fortify\Features;
@@ -13,4 +18,79 @@ abstract class TestCase extends BaseTestCase
$this->markTestSkipped($message ?? "Fortify feature [{$feature}] is not enabled.");
}
}
/**
* @param array<int, Permission|string> $permissions
*/
protected function syncGlobalPermissions(User $user, array $permissions): void
{
app(PermissionService::class)->sync(
$user,
null,
$this->normalizePermissions($permissions, 'global'),
);
}
/**
* @param array<int, Permission|string> $permissions
*/
protected function syncProjectPermissions(Project $project, User|Team $assignee, array $permissions): void
{
app(PermissionService::class)->sync(
$assignee,
$project,
$this->normalizePermissions($permissions, 'project'),
);
}
/**
* @param array<int, Permission|string> $permissions
*/
protected function syncTeamPermissions(Team $team, User $user, array $permissions): void
{
app(PermissionService::class)->sync(
$user,
$team,
$this->normalizePermissions($permissions, 'team'),
);
}
/**
* @param array<int, Permission|string> $permissions
* @return array<int, string>
*/
private function normalizePermissions(array $permissions, string $scope): array
{
return collect($permissions)
->map(function (Permission|string $permission) use ($scope): string {
if ($permission instanceof Permission) {
return $permission->value;
}
return match ($scope) {
'global' => match ($permission) {
'admin', 'global:admin' => Permission::GlobalAdmin->value,
'create', 'project:create' => Permission::ProjectCreate->value,
default => $permission,
},
'project' => match ($permission) {
'admin', 'delete', 'project:admin' => Permission::ProjectAdmin->value,
'edit' => Permission::ProjectAdmin->value,
'create', 'project:write', 'write' => Permission::ProjectWrite->value,
'project:read', 'read', 'view' => Permission::ProjectRead->value,
default => $permission,
},
'team' => match ($permission) {
'admin', 'team:admin' => Permission::TeamAdmin->value,
'invite', 'team:invite' => Permission::TeamInvite->value,
'manage', 'team:manage' => Permission::TeamManage->value,
default => $permission,
},
default => $permission,
};
})
->unique()
->values()
->all();
}
}