diff --git a/.github/workflows/php-ci.yml b/.github/workflows/php-ci.yml
index 8316eb6..e94b34c 100644
--- a/.github/workflows/php-ci.yml
+++ b/.github/workflows/php-ci.yml
@@ -24,4 +24,55 @@ jobs:
test:
name: PEST Tests
- uses: SecPal/.github/.github/workflows/reusable-php-test.yml@main
+ runs-on: ubuntu-latest
+ if: ${{ !startsWith(github.head_ref, 'spike/') && !startsWith(github.ref, 'refs/heads/spike/') }}
+
+ services:
+ postgres:
+ image: postgres:17-alpine
+ env:
+ POSTGRES_DB: db
+ POSTGRES_USER: db
+ POSTGRES_PASSWORD: db
+ options: >-
+ --health-cmd pg_isready
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+ ports:
+ - 5432:5432
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v5
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: "8.4"
+ extensions: mbstring, xml, ctype, iconv, intl, pdo, pdo_pgsql
+ coverage: xdebug
+
+ - name: Get Composer Cache Directory
+ id: composer-cache
+ run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"
+
+ - name: Cache Composer dependencies
+ uses: actions/cache@v4
+ with:
+ path: ${{ steps.composer-cache.outputs.dir }}
+ key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
+ restore-keys: ${{ runner.os }}-composer-
+
+ - name: Install dependencies
+ run: composer install --prefer-dist --no-progress --no-interaction
+
+ - name: Run tests
+ env:
+ DB_CONNECTION: pgsql
+ DB_HOST: localhost
+ DB_PORT: 5432
+ DB_DATABASE: db
+ DB_USERNAME: db
+ DB_PASSWORD: db
+ run: php artisan test
diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml
index a9659df..4427c92 100644
--- a/.github/workflows/quality.yml
+++ b/.github/workflows/quality.yml
@@ -44,7 +44,56 @@ jobs:
pest:
name: PEST Tests
- uses: SecPal/.github/.github/workflows/reusable-php-test.yml@main
- with:
- php-version: "8.4"
- test-command: "./vendor/bin/pest"
+ runs-on: ubuntu-latest
+ # Skip tests for spike branches (exploration/prototyping)
+ # Spike branches are for experimentation and cannot be merged to main
+ # See: CONTRIBUTING.md - Spike Branch Policy
+ if: ${{ !startsWith(github.head_ref, 'spike/') && !startsWith(github.ref, 'refs/heads/spike/') }}
+ services:
+ postgres:
+ image: postgres:17-alpine
+ env:
+ POSTGRES_DB: testing
+ POSTGRES_USER: testing
+ POSTGRES_PASSWORD: testing
+ options: >-
+ --health-cmd="pg_isready -U testing"
+ --health-interval=10s
+ --health-timeout=5s
+ --health-retries=5
+ ports:
+ - 5432:5432
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v5
+
+ - name: Setup PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: "8.4"
+ extensions: mbstring, xml, ctype, iconv, intl, pdo_pgsql
+ coverage: xdebug
+
+ - name: Get Composer Cache Directory
+ id: composer-cache
+ run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT"
+
+ - name: Cache Composer dependencies
+ uses: actions/cache@v4
+ with:
+ path: ${{ steps.composer-cache.outputs.dir }}
+ key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
+ restore-keys: ${{ runner.os }}-composer-
+
+ - name: Install dependencies
+ run: composer install --prefer-dist --no-progress --no-interaction
+
+ - name: Run Tests
+ env:
+ DB_CONNECTION: pgsql
+ DB_HOST: localhost
+ DB_PORT: 5432
+ DB_DATABASE: testing
+ DB_USERNAME: testing
+ DB_PASSWORD: testing
+ run: ./vendor/bin/pest
diff --git a/composer.json b/composer.json
index d9d219c..9e27d72 100644
--- a/composer.json
+++ b/composer.json
@@ -11,7 +11,9 @@
"require": {
"php": "^8.4",
"laravel/framework": "^12.36",
- "laravel/tinker": "^2.10.1"
+ "laravel/sanctum": "^4.2",
+ "laravel/tinker": "^2.10.1",
+ "spatie/laravel-permission": "^6.22"
},
"require-dev": {
"fakerphp/faker": "^1.23",
diff --git a/composer.lock b/composer.lock
index 9e2dfa4..d7c0c2d 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "bc4e2c9929c273c34ede98c3b2d1bed7",
+ "content-hash": "8d74c4e4f05036508492120903d1e85f",
"packages": [
{
"name": "brick/math",
@@ -1331,6 +1331,70 @@
},
"time": "2025-09-19T13:47:56+00:00"
},
+ {
+ "name": "laravel/sanctum",
+ "version": "v4.2.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/laravel/sanctum.git",
+ "reference": "fd6df4f79f48a72992e8d29a9c0ee25422a0d677"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/laravel/sanctum/zipball/fd6df4f79f48a72992e8d29a9c0ee25422a0d677",
+ "reference": "fd6df4f79f48a72992e8d29a9c0ee25422a0d677",
+ "shasum": ""
+ },
+ "require": {
+ "ext-json": "*",
+ "illuminate/console": "^11.0|^12.0",
+ "illuminate/contracts": "^11.0|^12.0",
+ "illuminate/database": "^11.0|^12.0",
+ "illuminate/support": "^11.0|^12.0",
+ "php": "^8.2",
+ "symfony/console": "^7.0"
+ },
+ "require-dev": {
+ "mockery/mockery": "^1.6",
+ "orchestra/testbench": "^9.0|^10.0",
+ "phpstan/phpstan": "^1.10",
+ "phpunit/phpunit": "^11.3"
+ },
+ "type": "library",
+ "extra": {
+ "laravel": {
+ "providers": [
+ "Laravel\\Sanctum\\SanctumServiceProvider"
+ ]
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Laravel\\Sanctum\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Taylor Otwell",
+ "email": "taylor@laravel.com"
+ }
+ ],
+ "description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.",
+ "keywords": [
+ "auth",
+ "laravel",
+ "sanctum"
+ ],
+ "support": {
+ "issues": "https://github.com/laravel/sanctum/issues",
+ "source": "https://github.com/laravel/sanctum"
+ },
+ "time": "2025-07-09T19:45:24+00:00"
+ },
{
"name": "laravel/serializable-closure",
"version": "v2.0.6",
@@ -3278,6 +3342,89 @@
},
"time": "2025-09-04T20:59:21+00:00"
},
+ {
+ "name": "spatie/laravel-permission",
+ "version": "6.22.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/spatie/laravel-permission.git",
+ "reference": "8c87966ddc21893bfda54b792047473703992625"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/spatie/laravel-permission/zipball/8c87966ddc21893bfda54b792047473703992625",
+ "reference": "8c87966ddc21893bfda54b792047473703992625",
+ "shasum": ""
+ },
+ "require": {
+ "illuminate/auth": "^8.12|^9.0|^10.0|^11.0|^12.0",
+ "illuminate/container": "^8.12|^9.0|^10.0|^11.0|^12.0",
+ "illuminate/contracts": "^8.12|^9.0|^10.0|^11.0|^12.0",
+ "illuminate/database": "^8.12|^9.0|^10.0|^11.0|^12.0",
+ "php": "^8.0"
+ },
+ "require-dev": {
+ "laravel/passport": "^11.0|^12.0",
+ "laravel/pint": "^1.0",
+ "orchestra/testbench": "^6.23|^7.0|^8.0|^9.0|^10.0",
+ "phpunit/phpunit": "^9.4|^10.1|^11.5"
+ },
+ "type": "library",
+ "extra": {
+ "laravel": {
+ "providers": [
+ "Spatie\\Permission\\PermissionServiceProvider"
+ ]
+ },
+ "branch-alias": {
+ "dev-main": "6.x-dev",
+ "dev-master": "6.x-dev"
+ }
+ },
+ "autoload": {
+ "files": [
+ "src/helpers.php"
+ ],
+ "psr-4": {
+ "Spatie\\Permission\\": "src"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Freek Van der Herten",
+ "email": "freek@spatie.be",
+ "homepage": "https://spatie.be",
+ "role": "Developer"
+ }
+ ],
+ "description": "Permission handling for Laravel 8.0 and up",
+ "homepage": "https://github.com/spatie/laravel-permission",
+ "keywords": [
+ "acl",
+ "laravel",
+ "permission",
+ "permissions",
+ "rbac",
+ "roles",
+ "security",
+ "spatie"
+ ],
+ "support": {
+ "issues": "https://github.com/spatie/laravel-permission/issues",
+ "source": "https://github.com/spatie/laravel-permission/tree/6.22.0"
+ },
+ "funding": [
+ {
+ "url": "https://github.com/spatie",
+ "type": "github"
+ }
+ ],
+ "time": "2025-10-27T21:58:45+00:00"
+ },
{
"name": "symfony/clock",
"version": "v7.3.0",
diff --git a/database/migrations/2025_11_01_165633_create_tenant_keys_table.php b/database/migrations/2025_11_01_165633_create_tenant_keys_table.php
new file mode 100644
index 0000000..ab44406
--- /dev/null
+++ b/database/migrations/2025_11_01_165633_create_tenant_keys_table.php
@@ -0,0 +1,38 @@
+id();
+ $table->binary('dek_wrapped');
+ $table->binary('dek_nonce');
+ $table->binary('idx_wrapped');
+ $table->binary('idx_nonce');
+ $table->integer('key_version')->default(1);
+ $table->timestamp('created_at')->useCurrent();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('tenant_keys');
+ }
+};
diff --git a/database/migrations/2025_11_01_165901_create_person_table.php b/database/migrations/2025_11_01_165901_create_person_table.php
new file mode 100644
index 0000000..70e6d0c
--- /dev/null
+++ b/database/migrations/2025_11_01_165901_create_person_table.php
@@ -0,0 +1,49 @@
+id();
+ $table->foreignId('tenant_id')->constrained('tenant_keys')->onDelete('cascade');
+ $table->binary('email_enc');
+ $table->binary('email_idx');
+ $table->binary('phone_enc');
+ $table->binary('phone_idx');
+ $table->text('note_enc')->nullable();
+ $table->timestamps();
+
+ $table->index(['tenant_id', 'email_idx']);
+ $table->index(['tenant_id', 'phone_idx']);
+ });
+
+ // PostgreSQL-specific full-text search support
+ if (DB::connection()->getDriverName() === 'pgsql') {
+ DB::statement('ALTER TABLE person ADD COLUMN note_tsv tsvector');
+ DB::statement('CREATE INDEX person_note_tsv_idx ON person USING GIN (note_tsv)');
+ }
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::dropIfExists('person');
+ }
+};
diff --git a/phpunit.xml b/phpunit.xml
index 3d22d0e..e27f3b5 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -30,8 +30,8 @@ SPDX-License-Identifier: CC0-1.0
-
-
+
+
diff --git a/tests/Feature/PersonSchemaTest.php b/tests/Feature/PersonSchemaTest.php
new file mode 100644
index 0000000..4083796
--- /dev/null
+++ b/tests/Feature/PersonSchemaTest.php
@@ -0,0 +1,119 @@
+toBeTrue();
+});
+
+test('person has correct columns', function (): void {
+ expect(Schema::hasColumns('person', [
+ 'id',
+ 'tenant_id',
+ 'email_enc',
+ 'email_idx',
+ 'phone_enc',
+ 'phone_idx',
+ 'note_enc',
+ 'note_tsv',
+ 'created_at',
+ 'updated_at',
+ ]))->toBeTrue();
+});
+
+test('person binary columns have bytea type', function (): void {
+ $columns = DB::select("
+ SELECT column_name, data_type
+ FROM information_schema.columns
+ WHERE table_name = 'person'
+ AND column_name IN ('email_enc', 'email_idx', 'phone_enc', 'phone_idx')
+ ");
+
+ expect($columns)->toHaveCount(4);
+
+ foreach ($columns as $column) {
+ expect($column)->toBeObject();
+ expect($column->data_type)->toBe('bytea'); // @phpstan-ignore property.nonObject
+ }
+});
+
+test('person note_enc column exists and has text type', function (): void {
+ $column = DB::selectOne("
+ SELECT data_type
+ FROM information_schema.columns
+ WHERE table_name = 'person'
+ AND column_name = 'note_enc'
+ ");
+
+ expect($column)->toBeObject();
+ expect($column->data_type)->toBe('text'); // @phpstan-ignore property.nonObject
+});
+
+test('person note_tsv has tsvector type', function (): void {
+ $column = DB::selectOne("
+ SELECT data_type
+ FROM information_schema.columns
+ WHERE table_name = 'person'
+ AND column_name = 'note_tsv'
+ ");
+
+ expect($column)->toBeObject();
+ expect($column->data_type)->toBe('tsvector'); // @phpstan-ignore property.nonObject
+});
+
+test('person has tenant_id email_idx composite index', function (): void {
+ $index = DB::selectOne("
+ SELECT indexname
+ FROM pg_indexes
+ WHERE tablename = 'person'
+ AND indexdef LIKE '%tenant_id%email_idx%'
+ ");
+
+ expect($index)->not->toBeNull();
+});
+
+test('person has tenant_id phone_idx composite index', function (): void {
+ $index = DB::selectOne("
+ SELECT indexname
+ FROM pg_indexes
+ WHERE tablename = 'person'
+ AND indexdef LIKE '%tenant_id%phone_idx%'
+ ");
+
+ expect($index)->not->toBeNull();
+});
+
+test('person has GIN index on note_tsv', function (): void {
+ $index = DB::selectOne("
+ SELECT indexname, indexdef
+ FROM pg_indexes
+ WHERE tablename = 'person'
+ AND indexname = 'person_note_tsv_idx'
+ ");
+
+ expect($index)->not->toBeNull();
+ expect($index)->toBeObject();
+ expect($index->indexdef)->toContain('USING gin'); // @phpstan-ignore property.nonObject
+});
+
+test('person has foreign key to tenant_keys', function (): void {
+ $fk = DB::selectOne("
+ SELECT constraint_name
+ FROM information_schema.table_constraints
+ WHERE table_name = 'person'
+ AND constraint_type = 'FOREIGN KEY'
+ AND constraint_name LIKE '%tenant_id%'
+ ");
+
+ expect($fk)->not->toBeNull();
+});
diff --git a/tests/Feature/TenantKeysSchemaTest.php b/tests/Feature/TenantKeysSchemaTest.php
new file mode 100644
index 0000000..f93221e
--- /dev/null
+++ b/tests/Feature/TenantKeysSchemaTest.php
@@ -0,0 +1,57 @@
+toBeTrue();
+});
+
+test('tenant_keys has correct columns', function (): void {
+ expect(Schema::hasColumns('tenant_keys', [
+ 'id',
+ 'dek_wrapped',
+ 'dek_nonce',
+ 'idx_wrapped',
+ 'idx_nonce',
+ 'key_version',
+ 'created_at',
+ ]))->toBeTrue();
+});
+
+test('tenant_keys binary columns have bytea type', function (): void {
+ $columns = DB::select("
+ SELECT column_name, data_type
+ FROM information_schema.columns
+ WHERE table_name = 'tenant_keys'
+ AND column_name IN ('dek_wrapped', 'dek_nonce', 'idx_wrapped', 'idx_nonce')
+ ");
+
+ expect($columns)->toHaveCount(4);
+
+ foreach ($columns as $column) {
+ expect($column)->toBeObject();
+ expect($column->data_type)->toBe('bytea'); // @phpstan-ignore property.nonObject
+ }
+});
+
+test('tenant_keys key_version has integer type', function (): void {
+ $column = DB::selectOne("
+ SELECT data_type
+ FROM information_schema.columns
+ WHERE table_name = 'tenant_keys'
+ AND column_name = 'key_version'
+ ");
+
+ expect($column)->toBeObject();
+ expect($column->data_type)->toBe('integer'); // @phpstan-ignore property.nonObject
+});