Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/auto-assign.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,14 @@ on:
pull_request:
types: [opened, reopened]

permissions:
pull-requests: write
issues: write

jobs:
assign:
runs-on: ubuntu-latest
if: github.actor != 'dependabot[bot]'
steps:
- uses: actions/github-script@v7
with:
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
# Documentation (local only)
/docs

# Dependencies
/vendor
/node_modules
Expand Down
112 changes: 103 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
- **Unified Status Lifecycle**: `draft` → `open` → `closed` (with re-open)
- **Dependency Tracking**: Declare dependencies between artifacts; check blockers before opening work
- **Module System**: Extend platform capabilities with pluggable modules
- **Multi-user Collaboration**: Role-based access control (owner, member)
- **Multi-tenancy (SaaS)**: Row-level tenant isolation with automatic Global Scope — transparent to API consumers
- **Multi-user Collaboration**: Two-tier access control — global user roles and per-project membership roles
- **RESTful API**: HTTP-based project management endpoints
- **MCP Server**: Native MCP 2.0 integration for AI agent control

Expand All @@ -39,8 +40,7 @@ The MCP server exposes:

- PHP 8.4+
- Laravel 12
- MariaDB 11.8+
- Docker (optional, for containerized deployment)
- MariaDB 11.8+ (or any compatible database)

### Quick Start

Expand Down Expand Up @@ -564,20 +564,114 @@ resources/mcp/agile-workflow.md
### Linting (PSR-12)

```bash
php artisan pint # Fix issues
php artisan pint --check # Check without fixing
./vendor/bin/pint # Fix issues
./vendor/bin/pint --test # Check without fixing
```

### Static Analysis (PHPStan Level 8)

```bash
php artisan stan
./vendor/bin/phpstan analyse --no-progress
```

### Testing (Pest)

```bash
php artisan test
./vendor/bin/pest
```

---

## Multi-tenancy

Poiesis supports **row-level multi-tenancy** for SaaS deployments. Each tenant has isolated users, projects, tokens, and artifacts.

### How it works

- A `BelongsToTenant` trait applies a **Global Scope** on all tenant-aware models (User, Project, ApiToken, OAuthClient, OAuthAccessToken, OAuthAuthorizationCode, Artifact)
- The tenant is resolved from the Bearer token in `AuthenticateBearer` middleware and stored in a `TenantManager` singleton
- All queries are automatically scoped — no code changes needed in controllers or MCP tools
- CLI commands use `withoutTenantScope()` to operate across tenants

### Tenant Management

```bash
# Create a tenant (optionally create an owner user)
php artisan tenant:create "Acme Corp" --slug=acme

# List all tenants
php artisan tenant:list

# Enable / disable a tenant
php artisan tenant:enable acme
php artisan tenant:disable acme

# Delete a tenant and ALL its data (cascading)
php artisan tenant:delete acme

# Assign orphan rows (tenant_id=null) to a tenant
php artisan tenant:assign-default acme

# Create a superadmin (separate from tenant users)
php artisan superadmin:create --name=admin --password=secret123
```

### User & Token with Tenant

```bash
# Create a user in a specific tenant
php artisan user:create --tenant=acme --role=1

# Create a token for a user in a specific tenant
php artisan token:create john --tenant=acme --name=agent
```

---

## Access Control

Poiesis uses two independent role systems that work together.

### Global User Role

Assigned at user creation, controls what operations the user can perform across the entire platform (MCP tools, REST API):

| `--role` | Name | Create/edit artifacts | Manage projects | Manage users |
| --- | --- | --- | --- | --- |
| `1` | `administrator` | yes | yes | yes |
| `2` | `manager` | yes | yes | no |
| `3` | `developer` | yes | no | no |
| `4` | `viewer` (default) | no | no | no |

```bash
php artisan user:create --role=2 # creates a manager
```

### Project Membership Role

Assigned per project, controls project-level administration:

| `--role` | Permissions |
| --- | --- |
| `owner` | delete project, manage members, activate/deactivate modules |
| `member` (default) | read/write access within the limits of the global role |

A user must have **both** an appropriate global role (to act on artifacts) **and** be a member of the project (to access it).

```bash
# Add a user to a project (project role: member, global role unchanged)
php artisan project:add-member PROJ claude.dev

# Add and set global role at the same time
php artisan project:add-member PROJ claude.manager --role=owner --policy=manager

# Update an existing member
php artisan project:update-member PROJ claude.dev --policy=developer
php artisan project:update-member PROJ claude.dev --role=owner

# List / remove
php artisan project:members PROJ
php artisan project:remove-member PROJ claude.dev
```

---
Expand All @@ -593,13 +687,13 @@ php artisan migrate:fresh --seed --seeder=DevSeeder
### Create a User

```bash
php artisan artisan:user:create
php artisan user:create [--tenant=acme] [--role=4]
```

### Generate MCP Token

```bash
php artisan artisan:token:create --name="Agent Name" --user-id=1
php artisan token:create <username> [--tenant=acme] [--name=default] [--expires=30d]
```

---
Expand Down
32 changes: 25 additions & 7 deletions app/Core/Console/Commands/ProjectAddMemberCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@

class ProjectAddMemberCommand extends Command
{
protected $signature = 'project:add-member {code} {user} {--role=member}';
protected $signature = 'project:add-member {code} {user} {--position=member} {--policy=}';

protected $description = 'Add a user to a project';

Expand All @@ -31,11 +31,11 @@ public function handle(): int
return self::FAILURE;
}

$role = $this->option('role');
$validRoles = config('core.project_roles');
$position = $this->option('position');
$validPositions = config('core.project_positions');

if (! in_array($role, $validRoles, true)) {
$this->error("Invalid role \"{$role}\". Valid roles: ".implode(', ', $validRoles));
if (! in_array($position, $validPositions, true)) {
$this->error("Invalid position \"{$position}\". Valid positions: ".implode(', ', $validPositions));

return self::FAILURE;
}
Expand All @@ -50,13 +50,31 @@ public function handle(): int
return self::FAILURE;
}

$policy = $this->option('policy');

if ($policy !== null && $policy !== '') {
$validPolicies = array_change_key_case(config('core.user_roles_int'), CASE_LOWER);
$policyInt = $validPolicies[strtolower($policy)] ?? null;

if ($policyInt === null) {
$this->error("Invalid policy \"{$policy}\". Valid policies: ".implode(', ', array_keys($validPolicies)));

return self::FAILURE;
}

$user->role = $policyInt;
$user->save();
}

ProjectMember::create([
'project_id' => $project->id,
'user_id' => $user->id,
'role' => $role,
'position' => $position,
]);

$this->info("Added \"{$user->name}\" to \"{$project->code}\" as {$role}.");
$msg = "Added \"{$user->name}\" to \"{$project->code}\" as {$position}";
$msg .= $policy ? " with policy {$policy}." : '.';
$this->info($msg);

return self::SUCCESS;
}
Expand Down
4 changes: 2 additions & 2 deletions app/Core/Console/Commands/ProjectMembersCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,11 @@ public function handle(): int
$members = $project->members()->with('user')->get();

$this->table(
['User ID', 'Name', 'Role', 'Member Since'],
['User ID', 'Name', 'Position', 'Member Since'],
$members->map(fn (ProjectMember $m) => [
$m->user_id,
$m->user->name,
$m->role,
$m->position,
$m->created_at->toDateTimeString(),
])
);
Expand Down
84 changes: 84 additions & 0 deletions app/Core/Console/Commands/ProjectUpdateMemberCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<?php

namespace App\Core\Console\Commands;

use App\Core\Models\Project;
use App\Core\Models\ProjectMember;
use App\Core\Models\User;
use Illuminate\Console\Command;

class ProjectUpdateMemberCommand extends Command
{
protected $signature = 'project:update-member {code} {user} {--policy=} {--position=}';

protected $description = 'Update a project member\'s role or policy';

public function handle(): int
{
$project = Project::where('code', $this->argument('code'))->first();

if ($project === null) {
$this->error("Project not found: {$this->argument('code')}");

return self::FAILURE;
}

$user = User::where('name', $this->argument('user'))->first();

if ($user === null) {
$this->error("User not found: {$this->argument('user')}");

return self::FAILURE;
}

$membership = ProjectMember::where('project_id', $project->id)
->where('user_id', $user->id)
->first();

if ($membership === null) {
$this->error("\"{$user->name}\" is not a member of project \"{$project->code}\".");

return self::FAILURE;
}

$policy = $this->option('policy');
$position = $this->option('position');

if ($policy === null && $position === null) {
$this->error('Provide at least --policy or --position.');

return self::FAILURE;
}

if ($policy !== null) {
$validPolicies = array_change_key_case(config('core.user_roles_int'), CASE_LOWER);
$policyInt = $validPolicies[strtolower($policy)] ?? null;

if ($policyInt === null) {
$this->error("Invalid policy \"{$policy}\". Valid policies: ".implode(', ', array_keys($validPolicies)));

return self::FAILURE;
}

$user->role = $policyInt;
$user->save();
}

if ($position !== null) {
$validPositions = config('core.project_positions');

if (! in_array($position, $validPositions, true)) {
$this->error("Invalid position \"{$position}\". Valid positions: ".implode(', ', $validPositions));

return self::FAILURE;
}

$membership->position = $position;
$membership->save();
}

$this->info("Updated \"{$user->name}\" in \"{$project->code}\".");

return self::SUCCESS;
}
}
2 changes: 1 addition & 1 deletion app/Core/Console/Commands/RoleSeedCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ public function handle(): int
ProjectMember::create([
'project_id' => $project->id,
'user_id' => $user->id,
'role' => 'member', // Project-level role, not user role
'position' => 'member',
]);

$this->info("Added {$user->name} to project {$project->code}");
Expand Down
45 changes: 45 additions & 0 deletions app/Core/Console/Commands/SuperadminCreateCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

namespace App\Core\Console\Commands;

use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;

class SuperadminCreateCommand extends Command
{
protected $signature = 'superadmin:create {--name=} {--password=}';

protected $description = 'Create a superadmin account';

public function handle(): int
{
$name = $this->option('name') ?: $this->ask('Name');
$password = $this->option('password') ?: $this->secret('Password');

if (empty($password) || strlen($password) < 8) {
$this->error('Password must be at least 8 characters.');

return self::FAILURE;
}

if (DB::table('superadmins')->where('name', $name)->exists()) {
$this->error("Superadmin already exists: {$name}");

return self::FAILURE;
}

DB::table('superadmins')->insert([
'id' => (string) Str::uuid(),
'name' => $name,
'password' => Hash::make($password),
'created_at' => now(),
'updated_at' => now(),
]);

$this->info("Superadmin created: {$name}");

return self::SUCCESS;
}
}
Loading
Loading