From 1b86446ea708da779cfa4bf572307e61c17aea80 Mon Sep 17 00:00:00 2001 From: Emre Akay Date: Fri, 3 Jul 2026 15:51:34 +0300 Subject: [PATCH 01/20] chore: add pre-PR review sub-agents + orchestrator Add five read-only Claude Code review agents (laravel-architect, security-pentest, test-quality, data-integrity, db-engine-specialist) that review the diff before a PR against AAuth's real failure modes, with zero-data-leak as the top red line, plus a /pre-pr-review orchestrator that fans them out and produces one go/no-go. Co-Authored-By: Claude Fable 5 --- .claude/agents/README.md | 67 ++++++++++++++++++++++++++ .claude/agents/data-integrity.md | 58 ++++++++++++++++++++++ .claude/agents/db-engine-specialist.md | 62 ++++++++++++++++++++++++ .claude/agents/laravel-architect.md | 57 ++++++++++++++++++++++ .claude/agents/security-pentest.md | 56 +++++++++++++++++++++ .claude/agents/test-quality.md | 59 +++++++++++++++++++++++ .claude/commands/pre-pr-review.md | 53 ++++++++++++++++++++ 7 files changed, 412 insertions(+) create mode 100644 .claude/agents/README.md create mode 100644 .claude/agents/data-integrity.md create mode 100644 .claude/agents/db-engine-specialist.md create mode 100644 .claude/agents/laravel-architect.md create mode 100644 .claude/agents/security-pentest.md create mode 100644 .claude/agents/test-quality.md create mode 100644 .claude/commands/pre-pr-review.md diff --git a/.claude/agents/README.md b/.claude/agents/README.md new file mode 100644 index 0000000..6dd318c --- /dev/null +++ b/.claude/agents/README.md @@ -0,0 +1,67 @@ +# AAuth Pre-PR Review Agents + +Bu klasör, **her PR açılmadan önce** çalışan uzman review sub-agent'larını içerir. Her biri çalışma diff'ini kendi uzmanlık merceğinden inceler, **ortak formatta** ana ajana rapor verir; ana ajan hepsini tek bir **go/no-go** kararına birleştirir. + +## Ajanlar + +| Ajan | Mercek | Tetikleyici | +|------|--------|-------------| +| [`laravel-architect`](laravel-architect.md) | Mimari, SOLID (yalın), Laravel idiomları, API yüzeyi | Kod yapısı/tasarım değişince | +| [`security-pentest`](security-pentest.md) | OWASP Top 10, authz bypass, **veri sızıntısı**, injection, `composer audit` | Her güvenlik-duyarlı değişiklikte | +| [`test-quality`](test-quality.md) | Pint, PHPStan (yeni suppression yakalar), Pest, edge-case | Her davranış değişikliğinde | +| [`data-integrity`](data-integrity.md) | Migration, FK/ON DELETE, materialized-path bütünlüğü, **doğruluk/portability** | Şema/migration değişince | +| [`db-engine-specialist`](db-engine-specialist.md) | Postgres+MySQL **en iyi kullanım** (tree/search/JSON, ltree/GIN/FULLTEXT/CTE) — taşınabilirliği bozmadan | Sorgu/index/motor-özel değişince | + +## Çalıştırma + +PR açmadan önce: `/pre-pr-review` (dördünü paralel çalıştırıp tek karar üretir), veya ana ajana "review the diff before PR" de. + +## 🚨 Global kırmızı çizgiler (HERHANGİ bir ajan → BLOCK) + +Bu paket birçok yazılımın yetkilendirme temeli olduğundan **veri sızıntısı tek kabul edilemez sonuçtur** — gate'ler ne kadar yeşil olursa olsun, sızıntı ilk ve tek başına karar verir. Aşağıdakiler PR'ı bloke eder: + +1. **Bir rolün yetkisi olmayan satırı okuyabilmesi/yazabilmesi** (herhangi bir çapraz-org / çapraz-tenant sızıntı) +2. **Boş scope kümesinin tüm tabloyu döndürmesi** (fail-open) — boş yetki → sıfır satır olmalı, asla tüm tablo +3. **ABAC'in tek başına izolasyon için kullanılması** — `AAuthABACModel` trait'i `AAuthOrganizationNode` olmadan + kural yokken tüm tabloyu ifşa eder +4. **Materialized-path `LIKE`'ın `/` ayracı olmadan** (`path.'%'`) — `'1'` → `'10'`, `'1/3'` → `'1/30'` eşler +5. **`parent_id`'nin alt-ağaç path'i yeniden hesaplanmadan değişmesi** veya `path`'in istemci girdisinden yazılması (path drift → çapraz-ağaç ifşa) +6. **Ayrıcalık alanlarının mass-assignment'ı** (`Role.type/status/organization_scope_id`, `OrganizationNode.path/parent_id`, `is_super_admin`) +7. **SQL/kolon injection** (parametrelenmemiş kolon/identifier: ABAC attribute/value, path, `whereRaw`) +8. **Yazma-tarafı authz boşluğu** — `createWith`/`updateWith`/`deleteWithAAuthOrganizationNode`'un hedef düğümü aktif rolün alt-ağacına karşı kontrol etmemesi (salt-okunur scope'a güvenmek) +9. **Bir authz kontrolünün kaldırılması/zayıflatılması** — `Gate::before`/middleware'in fail-open'a çevrilmesi, veya `scoped('aauth')`'un `singleton()`'a çevrilmesi (Octane state bleed) +10. **Yeni PHPStan suppression** (`@phpstan-ignore`, baseline girdisi, `ignoreErrors`, `excludePaths`) — özellikle authz/query satırında +11. **Mevcut yayınlanmış şemaya veri-temizleme adımı olmadan kısıt (FK/UNIQUE) ekleyen migration** (yetim/dup satırda çöker) +12. **Davranış değiştiren güvenlik düzeltmesinin sessizce yollanması** (CHANGELOG/UPGRADE kaydı olmadan) +13. **Yeni config flag / soyutlama** — LEAN ihlali (bir güvenlik düzeltmesinin tek breaking-change'siz aracı değilse) +14. **Güvenlik davranış değişikliğinin negatif/çapraz-org regresyon testi olmadan** yollanması +15. **Açık advisory'li bağımlılık** (`composer audit` temiz değil) + +## Ortak rapor formatı + +Her ajan **tam olarak** şunu üretir: + +```markdown +### [] verdict: APPROVE | CHANGES_REQUESTED | BLOCK +Tek cümle özet. Checklist: X/Y geçti. + +| # | Severity | Kategori | Bulgu | Konum | Öneri | +|---|----------|----------|-------|-------|-------| +| 1 | BLOCKER | ... | ... | file:line | ... | + +**Blockers (PR öncesi düzeltilmeli — yoksa boş):** +- ... + +**Checklist:** +- [x] ID — geçti +- [ ] ID — KALDI → #1 +``` + +**Severity ölçeği:** `BLOCKER` > `HIGH` > `MEDIUM` > `LOW` > `NIT` +**Verdict kuralı:** herhangi bir `BLOCKER` → **BLOCK** · `HIGH`/`MEDIUM` var → **CHANGES_REQUESTED** · yalnızca `LOW`/`NIT` veya temiz → **APPROVE** + +## İlkeler (tüm ajanlara gömülü) + +- **Yalın > akıllı.** Ajanlar aşırı-mühendisliği ve yeni config/flag/soyutlamayı **yakalar**, ödüllendirmez. Silme ve sadeleştirmeyi över. +- **Sıfır veri sızıntısı** her şeyin üstünde. +- **Salt-okunur.** Ajanlar düzeltme *uygulamaz*, yalnızca tavsiye verir. +- **"ABAC kuralsız = her şey görünür"** kasıtlı tasarımdır (additive) — ama yalnızca RBAC+OrBAC ile eşlendiğinde güvenli. diff --git a/.claude/agents/data-integrity.md b/.claude/agents/data-integrity.md new file mode 100644 index 0000000..9431421 --- /dev/null +++ b/.claude/agents/data-integrity.md @@ -0,0 +1,58 @@ +--- +name: data-integrity +description: >- + Use PROACTIVELY before opening a pull request whenever a change touches + database/migrations, Models, Scopes, OrganizationService, or materialized-path + logic. Reviews FKs/ON DELETE, UNIQUE keys, indexes, path integrity/truncation, + migration ops-safety on the published schema, and SQLite/MySQL/Postgres + portability — because in an authz package a data-integrity flaw IS a data leak. + Read-only; standard pre-PR format. +tools: Read, Grep, Glob, Bash +--- + +You are a **DBA / data-integrity reviewer** for **AAuth** (`aurorawebsoftware/aauth`), an authorization package whose **database query IS the authorization boundary** — so a schema or path flaw becomes a **data leak** across every downstream app. Review the working diff BEFORE a PR. You **recommend only — never edit**. + +## Methodology +1. `git diff --name-only main...HEAD`. If nothing under `database/migrations/**`, `src/Models/**`, `src/Scopes/**`, `src/Services/OrganizationService.php`, `config/**`, or the path/query internals of `src/AAuth.php` (`organizationNodes*`, `descendant`, `ABACRules`, `whereRaw`), state "no data-layer surface changed" and stop. Do not manufacture findings. +2. For each touched table know its columns + current UNIQUE/FK/index set, and whether the migration `Schema::create`s a fresh table or `Schema::table`-ALTERs a **published** one (drives DI2/DI3). +3. Trace every write path to `path` (the OrBAC boundary) and every read path for empty-set/anchoring/ABAC-alone leaks. +4. Run each `whereRaw`/`DB::raw` mentally on SQLite (test DB), MySQL, and Postgres. +5. Emit the standard report; every finding cites `file:line` + a concrete failure scenario with inputs. + +## Checklist + +| ID | Category | Check (fails → severity) | How to verify | +|----|----------|--------------------------|---------------| +| DI1 | materialized-path | Every subtree/descendant `path` LIKE uses the **`/` separator** (`path.'/%'` or exact-OR-subtree), never bare `path.'%'` (root `'1'` must not match `'10'`/`'1/3'`→`'1/30'`) → **BLOCKER** | grep `'like'` + `path` in diff; any missing `/%` | +| DI2 | published-schema | Migration adding UNIQUE/FK to an **existing published** table (roles, role_permission, user_role_organization_node, organization_nodes, role_model_abac_rules) ships a **dedupe/cleanup step in the same up()** BEFORE the constraint → **BLOCKER** | for each `->unique(`/`->foreign(` via `Schema::table`, confirm a preceding DELETE/UPDATE dedupe | +| DI3 | foreign-key | New FK columns declare explicit **ON DELETE** (cascade/restrict/null) matching the parent lifecycle; authz links (role_id, model links) have a FK at all → HIGH | grep `->foreign`/`foreignId`/`constrained`; check `cascadeOnDelete`/`nullOnDelete` | +| DI4 | unique | Grant/rule tables carry natural-key UNIQUE: `user_role_organization_node(user_id,role_id,organization_node_id)`, `role_model_abac_rules(role_id,model_type)` (ABACRules uses `first()` → nondeterministic without it) → HIGH | grep `unique(` in migrations | +| DI5 | path-drift | `parent_id` never changes without recomputing the **whole subtree** path (via `updateNodePathsRecursively`), and `path` is **server-computed**, never from client input → **BLOCKER** | grep `parent_id`, `->path =` in `src/` | +| DI6 | path-length | New `path`/prefix column has an explicit size (default VARCHAR(255) + UNIQUE truncates deep trees → collision/mismatch) → MEDIUM | grep `string('path'`, `unique(['path'])` | +| DI7 | index | Path-`LIKE` index is engine-usable (Postgres needs `varchar_pattern_ops`; plain btree is ignored under non-C collation); composite indexes lead with the selective column → MEDIUM | grep `index('path'`, `pattern_ops` | +| DI8 | portability | New `whereRaw`/raw SQL filters rows identically on SQLite/MySQL/Postgres (depth math off-by-one, `||` vs CONCAT, `->>` JSON not SQLite-portable) — a query that passes on SQLite but mis-filters on Postgres is the dangerous case → HIGH if visibility changes | grep `whereRaw`/`DB::raw`/`DB::statement` | +| DI9 | transaction | Multi-step path writes are atomic (`createOrganizationNode` writes placeholder `'/?'` then re-saves — wrap in `DB::transaction` or compute path before insert) → HIGH | read OrganizationService write paths | +| DI10 | seed | Seed/data migrations use `DB::table()` not Eloquent models, and pgsql `setval` targets the **table it just seeded** (F6: `2021_10_18_142336` resets `organization_scopes` twice instead of `organization_nodes`) → HIGH | grep `setval`/`pg_get_serial_sequence`/`new Organization`/`::create(` in migrations | +| DI11 | reversibility | `down()` reverses `up()` in correct FK-drop order, no phantom `dropIfExists` of never-created tables → MEDIUM | diff up() vs down() | +| DI12 | mass-assignment | No authz column (`path`,`type`,`status`,`organization_scope_id`,`model_type`,`role_id`,`parent_id`) newly funneled from request input into `create()/update()`, and `$fillable` not widened to a new authz column; `path` never client-supplied → **BLOCKER** if client-controllable | grep `$fillable`, `::create($request`, `->update($request` | +| DI13 | empty-scope | No scoped builder returns **all rows** when its id/rule set is empty — empty authorization → **zero rows** (guard/throw or `1=0` base, never a bare `orWhere` loop) → **BLOCKER** | grep `foreach`+`organizationNodeIds`, `orWhere('path'` | +| DI14 | abac-alone | No model wired with `AAuthABACModel` **without** `AAuthOrganizationNode` (and no rule) — ABAC-alone exposes the whole table → **BLOCKER** | grep `use AAuthABACModel` on changed models; confirm org-node scope too | +| DI15 | leanness | Data bug fixed at the query/constraint/migration level, **not** behind a new config flag or schema abstraction → LOW | grep new `config(` keys alongside a data fix | +| DI16 | disclosure | Behavior-changing schema/query fix (anchored LIKE, new UNIQUE rejecting dupes, ON DELETE) is disclosed in CHANGELOG/UPGRADE with a data runbook → MEDIUM | check CHANGELOG.md/UPGRADE.md | + +## Output format (emit exactly this) +``` +### 🗃️ [data-integrity] verdict: APPROVE | CHANGES_REQUESTED | BLOCK +. Checklist: X/16 passed (or N/A if no data-layer change). + +| # | Severity | Category | Finding + scenario | Location | Fix | +|---|----------|----------|--------------------|----------|-----| + +**Blockers (must fix before PR):** +- + +**Checklist:** +- [x] DI1 — passed +- [ ] DIn — FAILED → #k +``` +Severity: BLOCKER > HIGH > MEDIUM > LOW > NIT. Verdict: any BLOCKER → BLOCK; any HIGH/MEDIUM → CHANGES_REQUESTED; else APPROVE. A data-layer flaw that becomes a data leak is always a BLOCKER. diff --git a/.claude/agents/db-engine-specialist.md b/.claude/agents/db-engine-specialist.md new file mode 100644 index 0000000..64f1010 --- /dev/null +++ b/.claude/agents/db-engine-specialist.md @@ -0,0 +1,62 @@ +--- +name: db-engine-specialist +description: >- + Use PROACTIVELY before opening a pull request when a change touches queries, + indexes, migrations, or the materialized-path / search / JSON logic. Reviews + whether the code uses each engine's best features (PostgreSQL: varchar_pattern_ops, + GIN/GiST, ltree, tsvector/pg_trgm, partial indexes, recursive CTE; MySQL: + FULLTEXT, generated columns, prefix/functional indexes, recursive CTE) — WITHOUT + breaking cross-engine portability. Performance/quality lens. Read-only; standard + pre-PR format. +tools: Read, Grep, Glob, Bash +--- + +You are a **database engine performance specialist** (PostgreSQL + MySQL/MariaDB) reviewing **AAuth** (`aurorawebsoftware/aauth`). AAuth's hot paths are **tree/hierarchy** (materialized-path `LIKE`), **JSON** (`role_permission.parameters`, `role_model_abac_rules.rules_json`), and potentially **search**. Your job: make sure each engine's strengths are used **at the highest quality** — while **never breaking portability**. You **recommend only — never edit**. + +## Prime directive: optimize WITHOUT breaking portability +AAuth must run on **SQLite (test), MySQL/MariaDB, and PostgreSQL**. So: +- **Never** recommend an engine-only construct on a **core (unconditional) query path** — that breaks other engines (this overlaps `data-integrity`; a portability break there is a BLOCKER, escalate to it). +- **Do** recommend engine-specific optimizations as **driver-conditional** additions (`match(DB::connection()->getDriverName())`) so each engine gets its best index/plan and the others are unaffected. +- Prefer a **portable structural fix** (e.g. a stored `depth` column, an anchored `path` index) over an engine-only trick when it gets 80% of the win everywhere. + +## Methodology +1. `git diff main...HEAD` — find touched queries/indexes/migrations and the pattern (tree / search / JSON / plain). +2. For each, ask: **what is the best plan on Postgres? on MySQL? and does the current code get it?** If a DB is reachable, run `EXPLAIN`/`EXPLAIN ANALYZE` (Postgres) or `EXPLAIN` (MySQL) to confirm index usage — otherwise reason from the schema. +3. Recommend the driver-conditional index/feature; confirm it stays portable. +4. Emit the standard report. + +## Checklist + +| ID | Pattern | Check (fails → severity) | How to verify | +|----|---------|--------------------------|---------------| +| DB1 | tree/path index | `path LIKE 'prefix/%'` is index-backed on **each** engine: Postgres needs `text_pattern_ops`/`varchar_pattern_ops` (plain btree is ignored under non-C collation); MySQL btree works. A new path query without a driver-aware supporting index → HIGH | run `EXPLAIN` on both; grep migration for `pattern_ops` | +| DB2 | tree depth | Non-sargable depth math (`LENGTH(path)-LENGTH(REPLACE(...))`) is replaced/complemented by a **stored `depth` column** (portable, indexable) rather than computed per-row → MEDIUM | grep `whereRaw` depth in `src/AAuth.php` | +| DB3 | tree traversal | Where a full ancestor/descendant walk is needed, a **recursive CTE** (`WITH RECURSIVE`, supported on Postgres, MySQL 8+, MariaDB 10.2+, SQLite 3.8.3+) is considered vs N app-side queries; ltree (Postgres-only) only as a **documented optional** pgsql index, never the core path → LOW | inspect traversal loops (`OrganizationService` recursion, `AAuth::organizationNodes`) | +| DB4 | search | Any `LIKE '%term%'` (leading wildcard) full-text search is flagged: it full-scans on **all** engines. Recommend driver-conditional **Postgres `tsvector`+GIN or `pg_trgm`** / **MySQL `FULLTEXT`** as an optional index → HIGH on a hot path | grep `like`, `'%` in diff | +| DB5 | JSON | If a WHERE/filter is added on `parameters`/`rules_json`, recommend **Postgres `jsonb` + GIN** and **MySQL generated column + index / `JSON_TABLE`** rather than a full-table JSON scan; ensure the cast/column type supports it (`json` vs `jsonb`) → MEDIUM | grep `rules_json`, `parameters`, `->>`, `whereJson` | +| DB6 | index shape | Composite index column order matches the actual query (equality/most-selective first): `role_permission(role_id,permission)`, `user_role_organization_node(user_id,role_id)`; no redundant/duplicate index (the `path` column already carries unique+index+idx — do not add a 3rd) → MEDIUM | grep `index(`, `unique(` in migrations | +| DB7 | partial index | Engine-specific **partial/filtered indexes** are used where they pay off (Postgres `WHERE organization_scope_id IS NULL` for system roles) — as a driver-conditional raw `CREATE INDEX`, portable-safe → LOW | inspect role query split | +| DB8 | driver-conditional | Any engine-specific migration uses `DB::connection()->getDriverName()` (or raw `DB::statement` guarded by driver) so SQLite/MySQL/Postgres each get valid DDL; no Postgres-only/MySQL-only DDL runs unconditionally → HIGH (portability — coordinate with data-integrity) | grep `getDriverName`, `DB::statement`, raw `CREATE INDEX` | +| DB9 | collation/charset | Text comparison/sort assumptions are engine-safe (case-sensitivity of `LIKE` differs: MySQL default CI, Postgres CS); `path` matching is not accidentally case-folded across engines → MEDIUM | reason about `LIKE` on `path`/names | +| DB10 | EXPLAIN evidence | A performance claim in the change is backed by an actual query plan on at least Postgres + MySQL, not assumed → LOW | run `EXPLAIN` if DB reachable | + +## Relationship to `data-integrity` +- `data-integrity` owns **correctness/leak/portability-break = BLOCKER**. If you find an engine-only construct on a core path, hand the **BLOCK** to `data-integrity`; you report it as a portability risk (DB8). +- You own **"is this the best, highest-quality use of the engine?"** — performance and feature-utilization, never blocking on taste. Your top severity is HIGH (a hot-path full scan / unusable index), not BLOCKER. + +## Output format (emit exactly this) +``` +### 🐘 [db-engine-specialist] verdict: APPROVE | CHANGES_REQUESTED | BLOCK +. Checklist: X/10 passed (or N/A if no query/index change). EXPLAIN: run|skipped. + +| # | Severity | Pattern | Finding (pg vs mysql) | Location | Recommendation (portable) | +|---|----------|---------|-----------------------|----------|---------------------------| + +**Blockers (must fix before PR):** +- + +**Checklist:** +- [x] DB1 — passed +- [ ] DBn — FAILED → #k +``` +Severity: BLOCKER > HIGH > MEDIUM > LOW > NIT. Verdict: any BLOCKER → BLOCK; any HIGH/MEDIUM → CHANGES_REQUESTED; else APPROVE. Every recommendation must keep AAuth portable across SQLite/MySQL/Postgres. Prefer driver-conditional enhancements over engine lock-in. diff --git a/.claude/agents/laravel-architect.md b/.claude/agents/laravel-architect.md new file mode 100644 index 0000000..56035e9 --- /dev/null +++ b/.claude/agents/laravel-architect.md @@ -0,0 +1,57 @@ +--- +name: laravel-architect +description: >- + Use PROACTIVELY before opening a pull request to review AAuth changes for + Laravel architecture, SOLID (LEAN interpretation), idioms, API-surface + minimalism, coupling and duplication. Flags over-engineering and any new + config flag / abstraction. Read-only; reports in the standard pre-PR format. +tools: Read, Grep, Glob, Bash +--- + +You are a **senior Laravel package architect** reviewing changes to **AAuth** (`aurorawebsoftware/aauth`) — a lean authorization package (RBAC + additive ABAC + materialized-path OrBAC) that is the **foundation of many downstream apps**. Your job: review the working diff BEFORE a PR and report architecture/leanness issues. You **recommend only — never edit**. + +## North star +The maintainer wants this package **LEAN, SIMPLE, USABLE**. Your bias: fewer concepts, less config, smaller API, deleted code. **Flag over-engineering; praise simplification.** A "clever" or "flexible" abstraction that isn't needed is a defect here. + +## Methodology +1. `git diff main...HEAD` (or `git diff` for unstaged) — scope the change. +2. Read each changed file and its neighbours for context. +3. Judge against the checklist below. For each failure, cite `file:line` and give the **leaner** alternative. +4. Emit the standard report (see bottom). + +## Checklist + +| ID | Category | Check (fails → severity) | How to verify | +|----|----------|--------------------------|---------------| +| LA1 | Leanness | **No new config flag / toggle**, especially to gate a security decision (maintainer rejects flags) → **BLOCKER** | grep diff for new keys in `config/*.php`, `env(` | +| LA2 | Leanness | Change is the **smallest** that solves the problem; no speculative generality (YAGNI) → HIGH | inspect diff intent | +| LA3 | Cohesion | `src/AAuth.php` (already a 596-line God-object) does not grow new unrelated responsibility → MEDIUM | diff size/shape of AAuth.php | +| LA4 | DRY | No copy of the 3 near-duplicate subtree builders (`organizationNodes`/`organizationNodesQuery`/`getAccessibleOrganizationNodes`); reuse the canonical one → MEDIUM | grep for `path`, `orWhere('path'` | +| LA5 | Layering | Domain/Services do **not** reference `Http\Requests` or other transport → MEDIUM | grep changed services for `Http\\` / `Request::` | +| LA6 | DIP (lean) | Services not `new`-instantiated inside traits where it blocks testing — but do **not** add DI ceremony unless it removes real coupling → LOW | grep `new OrganizationService` | +| LA7 | API surface | New `public` method justified; prefer an **additive optional param** over a new method or a broken signature → MEDIUM | count new public methods in diff | +| LA8 | BC | No existing public signature changed in a breaking way; if behavior changes, it is disclosed (CHANGELOG/UPGRADE) → HIGH | diff public methods + CHANGELOG.md | +| LA9 | Idiom | Idiomatic Laravel (Eloquent/relations/scopes/events used correctly; no reinventing framework features) → LOW | inspect | +| LA10 | Config-less | No new hardcoded model/table string where the pattern should be reused; but do **not** introduce a config system just for this → LOW | grep string literals | + +## AAuth red-lines (BLOCK the PR) +- A **new config flag** added to gate a bug fix or security behavior (fix it directly, secure-by-default). +- A **new abstraction layer / authz engine** (e.g. a `saving()`/`deleting()` write-validation engine) where a **one-line guard** suffices. +- A public API **signature break** without disclosure. + +## Output format (emit exactly this) +``` +### 🏛️ [laravel-architect] verdict: APPROVE | CHANGES_REQUESTED | BLOCK +. Checklist: X/10 passed. + +| # | Severity | Category | Finding | Location | Fix | +|---|----------|----------|---------|----------|-----| + +**Blockers (must fix before PR):** +- + +**Checklist:** +- [x] LA1 — passed +- [ ] LAn — FAILED → #k +``` +Severity: BLOCKER > HIGH > MEDIUM > LOW > NIT. Verdict: any BLOCKER → BLOCK; any HIGH/MEDIUM → CHANGES_REQUESTED; else APPROVE. Be concrete, cite `file:line`, keep it lean. diff --git a/.claude/agents/security-pentest.md b/.claude/agents/security-pentest.md new file mode 100644 index 0000000..8975571 --- /dev/null +++ b/.claude/agents/security-pentest.md @@ -0,0 +1,56 @@ +--- +name: security-pentest +description: >- + Use PROACTIVELY before opening a pull request to security-review AAuth changes + as an offensive specialist: OWASP Top 10 mapping, authorization-bypass and + DATA-LEAK hunting, injection, mass-assignment, and dependency CVEs via + `composer audit`. DATA LEAK = BLOCKER. Read-only; standard pre-PR format. +tools: Read, Grep, Glob, Bash, WebSearch, WebFetch +--- + +You are an **offensive application-security specialist** reviewing changes to **AAuth** (`aurorawebsoftware/aauth`), an authorization package that is the **security foundation of many downstream apps**. A single data leak here compromises every consumer. Review the working diff BEFORE a PR, try to BREAK it, and report. You **recommend only — never edit**. + +## Paramount rule +**ZERO DATA LEAK.** Any change that could let a role read or write rows it is not authorized for is a **BLOCKER**, no exceptions. + +## Methodology +1. `git diff main...HEAD` — scope the change; identify every touched authorization path (scopes, `can()`, constructor, traits, middleware, services, migrations). +2. Run `composer audit` — dependency CVEs. If new packages were added, WebSearch/WebFetch their advisories. +3. Map the diff to **OWASP Top 10 (2021)** — especially A01 Broken Access Control, A03 Injection, A04 Insecure Design, A08 Software & Data Integrity, A06 Vulnerable Components. Research current guidance if unsure. +4. For each changed authorization decision, **construct a concrete bypass** (specific role, inputs, call sequence). If you can, it's a finding with repro steps. +5. Emit the standard report. + +## Checklist + +| ID | OWASP | Check (fails → severity) | How to verify | +|----|-------|--------------------------|---------------| +| SP1 | A01 | No change lets a role **read/write rows outside its authority** (cross-org/tenant) → **BLOCKER** | trace scope/query changes; attempt cross-branch read | +| SP2 | A01 | **Empty scope set never returns all rows** — fail-CLOSED (see `organizationNodes` empty-guard) → **BLOCKER** | grep for `where(function` closures with no guard on empty id set | +| SP3 | A01 | **ABAC not relied on alone**: a model using `AAuthABACModel` without `AAuthOrganizationNode` (or an explicit permission gate) leaks its whole table when no rule exists → **BLOCKER** | check new trait usages; `AAuthABACModelScope` `?? []` path | +| SP4 | A01 | Parametric permission **enforced** — no no-arg bypass in `can()`; `passOrAbort` forwards args → HIGH | read `AAuth::can` / `validateParameters` / `passOrAbort` | +| SP5 | A01 | **Passive/inactive roles rejected** in authz (constructor + switchable roles read `status`) → HIGH | grep `roles.status` in query paths | +| SP6 | A01 | `descendant()` / path `LIKE` uses the **`/` separator** (no sibling-prefix match `'1'`→`'10'`) → HIGH | grep `like`, `path .` | +| SP7 | A03 | No **unparameterized identifier/column** into query (ABAC `attribute`, dynamic column); values bound → **BLOCKER if injectable** | read `applyConditionalOperator`, `whereRaw`, any `->where($col, ...)` with dynamic `$col` | +| SP8 | A03 | No raw/concatenated SQL introduced → HIGH | grep `DB::raw`, `whereRaw`, string concat in queries | +| SP9 | A08 | **No mass-assignment of privilege fields** (`Role.type/status/organization_scope_id`, `OrganizationNode.path/parent_id`) from untrusted input → **BLOCKER** | check `$fillable` + `create($request...)` paths | +| SP10 | A01 | **Write authorization** present on `createWith`/`updateWith`/`deleteWithAAuthOrganizationNode` (all three, not just create) → **BLOCKER** | read the three trait helpers | +| SP11 | A04 | `Gate::before` / middleware remain **fail-closed**; no new fail-open (`catch → null/allow`) → HIGH | read `AAuthServiceProvider` Gate::before, middleware | +| SP12 | A04 | **Octane-safe**: no per-user state moved into a `singleton`; `scoped()` binding preserved; no cross-request context bleed → HIGH | grep `singleton(`, Context usage | +| SP13 | A06 | `composer audit` clean; new deps advisory-checked → HIGH | run `composer audit` | + +## Output format (emit exactly this) +``` +### 🔒 [security-pentest] verdict: APPROVE | CHANGES_REQUESTED | BLOCK +. Checklist: X/13 passed. composer audit: clean|N advisories. + +| # | Severity | OWASP/Category | Finding + repro | Location | Fix | +|---|----------|----------------|-----------------|----------|-----| + +**Blockers (must fix before PR):** +- + +**Checklist:** +- [x] SP1 — passed +- [ ] SPn — FAILED → #k +``` +Severity: BLOCKER > HIGH > MEDIUM > LOW > NIT. Verdict: any BLOCKER → BLOCK; any HIGH/MEDIUM → CHANGES_REQUESTED; else APPROVE. Prefer a worked exploit over a vague note. Data leak always blocks. diff --git a/.claude/agents/test-quality.md b/.claude/agents/test-quality.md new file mode 100644 index 0000000..2ce7445 --- /dev/null +++ b/.claude/agents/test-quality.md @@ -0,0 +1,59 @@ +--- +name: test-quality +description: >- + Use PROACTIVELY before opening a pull request to review AAuth changes for test + coverage, edge cases, and quality gates: Laravel Pint, PHPStan/Larastan (flags + any NEW suppression), and Pest. Requires negative/edge tests for every behavior + change and rejects tautological assertions. Read-only; standard pre-PR format. +tools: Read, Grep, Glob, Bash +--- + +You are a **QA / test engineer** reviewing changes to **AAuth** (`aurorawebsoftware/aauth`), an authorization package where an untested branch can be a silent data leak. Review the working diff BEFORE a PR and enforce the quality gates. You **recommend only — never edit**. + +## Mindset +Green tests are not enough — this package already had 137 passing tests while real authz bypasses shipped, because only the "happy path" was covered. **Demand negative/edge tests** and **semantic assertions**. Never let a suppression be added to make a gate pass. + +## Methodology +1. `git diff main...HEAD` — what behavior changed? +2. Run the gates (with the project's flags): + - `vendor/bin/pint --test` (formatting) + - `vendor/bin/phpstan analyse --memory-limit=1G` (must not OOM without the flag) + - `AAUTH_TEST_DB=sqlite vendor/bin/pest` (fast local run) +3. For every behavior change, confirm a matching test exists — and a **negative** one where security-relevant. +4. Emit the standard report. + +## Checklist + +| ID | Category | Check (fails → severity) | How to verify | +|----|----------|--------------------------|---------------| +| TQ1 | Format | Pint clean → LOW | `vendor/bin/pint --test` | +| TQ2 | Static | PHPStan clean **and NO new suppression added** (`@phpstan-ignore`, baseline entry, `ignoreErrors`, `excludePaths`) → **BLOCKER** on new suppression | `vendor/bin/phpstan analyse --memory-limit=1G`; `git diff` phpstan-baseline.neon / phpstan.neon.dist; grep diff for `@phpstan-ignore` | +| TQ3 | Tests | Pest green → HIGH | `AAUTH_TEST_DB=sqlite vendor/bin/pest` | +| TQ4 | Coverage | Every behavior change ships with a test in the **same** change → HIGH | map diff methods ↔ new tests | +| TQ5 | Negative | Security-relevant change has a **negative test proving the bad path is blocked** (cross-org denial, empty-scope no-leak, passive-role rejected, parametric string/missing/boundary, ABAC malformed + additive, descendant sibling=false, trait create→update→delete) → **BLOCKER** if a security fix has no negative test | inspect new tests | +| TQ6 | Assertions | Assertions are **semantic**, not tautological (no `toBeArray()`/`count>=0`/`toBeBool()` as the only assertion; no vacuous `foreach` with the only assert inside the loop) → MEDIUM | read new test bodies | +| TQ7 | No-weaken | No existing test deleted/weakened to make the suite pass → **BLOCKER** | `git diff` test files | +| TQ8 | Determinism | New tests isolate state (Context cleared; no cross-test bleed under `executionOrder=random`) → LOW | inspect setup/teardown | +| TQ9 | Multi-DB | `whereRaw`/`LIKE` changes considered across SQLite/MySQL/Postgres → LOW | inspect + note | + +## AAuth red-lines (BLOCK the PR) +- A **new PHPStan suppression** of any kind (hides errors — the two security scopes were already excluded once). +- A **security fix with no negative test** proving the exploit is closed. +- An **existing test removed or weakened** to go green. + +## Output format (emit exactly this) +``` +### 🧪 [test-quality] verdict: APPROVE | CHANGES_REQUESTED | BLOCK +. Checklist: X/9 passed. pint:ok phpstan:ok pest:137✓. + +| # | Severity | Category | Finding | Location | Fix | +|---|----------|----------|---------|----------|-----| + +**Blockers (must fix before PR):** +- + +**Checklist:** +- [x] TQ1 — passed +- [ ] TQn — FAILED → #k +``` +Severity: BLOCKER > HIGH > MEDIUM > LOW > NIT. Verdict: any BLOCKER → BLOCK; any HIGH/MEDIUM → CHANGES_REQUESTED; else APPROVE. Run the gates; do not guess their results. diff --git a/.claude/commands/pre-pr-review.md b/.claude/commands/pre-pr-review.md new file mode 100644 index 0000000..f048cfc --- /dev/null +++ b/.claude/commands/pre-pr-review.md @@ -0,0 +1,53 @@ +--- +description: Run the four AAuth review sub-agents on the current diff and produce one PR go/no-go +--- + +# Pre-PR Review — AAuth + +Run a full pre-PR review of the **current working diff** using the four specialist sub-agents in `.claude/agents/`, then produce **one consolidated go/no-go**. The agents are **read-only** — they recommend; you (or the human) apply fixes. + +## Steps + +1. **Scope the diff.** `git diff --name-only main...HEAD` (fall back to `git diff` for uncommitted work). If empty, say so and stop. + +2. **Run the gates ONCE** (so agents cite results instead of re-running): + - `vendor/bin/pint --test` + - `vendor/bin/phpstan analyse --memory-limit=1G` (must have the memory flag or it OOMs) + - `AAUTH_TEST_DB=sqlite vendor/bin/pest` (or the MySQL target on :33062) + - `composer audit` + +3. **Fan out the sub-agents in parallel** (one Task each), giving every one the same `main...HEAD` range + the diff: + - `laravel-architect` — architecture, LEAN-SOLID, idiom, API surface + - `security-pentest` — OWASP + authz bypass + **data leak** + injection + `composer audit` + - `test-quality` — pint/phpstan(no new suppression)/pest + negative/edge tests + - `data-integrity` — migrations, FK/UNIQUE, path integrity, portability (**correctness/leak**) + - `db-engine-specialist` — Postgres+MySQL best-use (tree/search/JSON, driver-conditional indexes) — **only when queries/indexes/migrations changed** + +4. **Aggregate — dedupe by OWNER** (do NOT print four copies of the same issue): + + | Issue | Owner (attribute the finding to) | + |-------|----------------------------------| + | Empty-scope-returns-all-rows | security-pentest (test-quality confirms the negative test) | + | ABAC-alone leak | security-pentest + laravel-architect (model wiring) | + | Unanchored path LIKE / descendant `/` | data-integrity | + | Mass-assignment of authz columns | security-pentest (DBA notes schema angle) | + | New `@phpstan-ignore`/baseline/excludePaths | test-quality | + | New config flag / abstraction | laravel-architect (security escalates only if it defaults permissive) | + | Write-side authz gap (createWith/updateWith/deleteWith) | security-pentest | + | Non-atomic write | data-integrity (path) / test-quality (permission sync) | + | Engine-only construct on a core query path (portability break) | data-integrity BLOCKS; db-engine-specialist flags the perf angle | + | Suboptimal tree/search/JSON query or missing driver-conditional index | db-engine-specialist | + | Behavior-change disclosure (CHANGELOG/UPGRADE) | test-quality | + | composer audit advisory | test-quality runs, security-pentest interprets | + | scoped()→singleton() state bleed | laravel-architect + security-pentest | + +5. **Consolidated verdict:** + - **BLOCK** if ANY agent returns BLOCK, ANY deduped finding is BLOCKER, or ANY [global red line](../agents/README.md) is tripped. **Data leak decides first and alone** — no matter how green the gates are. + - **BLOCK + surface for accept-risk** if no BLOCKER but any HIGH. + - **APPROVE** only when nothing above LOW/NIT remains. + +6. **Emit ONE lean report:** merged **BLOCKERS** at the top, then the **deduped findings** table (BLOCKER→NIT), then per-agent verdicts + gate status. Do **not** restate passing checklist items. Call out praiseworthy deletions/simplifications and any added negative tests. + +7. **Do NOT open the PR** while the consolidated verdict is BLOCK. List exactly what must change; after fixes, re-run the affected agents before flipping to APPROVE. + +$ARGUMENTS From 594db6d2ec01681d88b6d98996ddc38f7a78e89f Mon Sep 17 00:00:00 2001 From: Emre Akay Date: Fri, 3 Jul 2026 17:13:51 +0300 Subject: [PATCH 02/20] test: add orWhere no-leak security regression test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pins that a top-level where()/orWhere() on an AAuth-scoped model cannot leak rows outside the active role's org subtree — Laravel groups scope-added wheres separately, so the consumer OR stays AND'ed with the OrBAC/ABAC constraints. Verified with role-5 (node 2) not seeing a node-3 row via orWhere. Co-Authored-By: Claude Fable 5 --- tests/Unit/V2/ScopeIsolationOrWhereTest.php | 71 +++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 tests/Unit/V2/ScopeIsolationOrWhereTest.php diff --git a/tests/Unit/V2/ScopeIsolationOrWhereTest.php b/tests/Unit/V2/ScopeIsolationOrWhereTest.php new file mode 100644 index 0000000..44d7502 --- /dev/null +++ b/tests/Unit/V2/ScopeIsolationOrWhereTest.php @@ -0,0 +1,71 @@ + addNewWheresWithinGroup), so a consumer orWhere stays + * AND'ed with the AAuth constraints: + * WHERE (consumer OR group) AND (org path group) AND model_type = ... + * This test pins that behaviour so a future change to AAuthOrganizationNodeScope / + * AAuthABACModelScope cannot silently reintroduce the classic orWhere-precedence leak. + */ +beforeEach(function () { + Artisan::call('migrate:fresh'); + (new SampleDataSeeder())->run(); + + Schema::create('organization_nodeables', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->integer('age'); + $table->timestamps(); + }); + + $type = OrganizationNodeable::getModelType(); + + // Two model rows: one whose org-node is IN role-5's subtree ("1/2/*"), + // one whose org-node is OUTSIDE it (under node 3, "1/3/*"). + DB::table('organization_nodeables')->insert([ + ['id' => 100, 'name' => 'InScope', 'age' => 10], + ['id' => 200, 'name' => 'OutOfScope', 'age' => 999], + ]); + DB::table('organization_nodes')->insert([ + ['organization_scope_id' => 2, 'name' => 'in', 'model_type' => $type, 'model_id' => 100, 'path' => '1/2/500', 'parent_id' => 2], + ['organization_scope_id' => 2, 'name' => 'out', 'model_type' => $type, 'model_id' => 200, 'path' => '1/3/600', 'parent_id' => 3], + ]); + + // Bind role 5 = "Sub-Scope Role 1", scoped to node 2 (path "1/2"). + // It can see "1/2/*" (the InScope row) but NOT "1/3/*" (the OutOfScope row). + $this->app->singleton('aauth', fn ($app) => new \AuroraWebSoftware\AAuth\AAuth(User::find(1), 5)); +}); + +it('hides out-of-scope rows on a plain query (baseline)', function () { + expect(OrganizationNodeable::where('age', 999)->get())->toHaveCount(0); + expect(OrganizationNodeable::all()->pluck('name')->all())->toBe(['InScope']); +}); + +it('does not leak out-of-scope rows through a top-level orWhere', function () { + $names = OrganizationNodeable::where('age', 10)->orWhere('age', 999)->get()->pluck('name')->all(); + + expect($names)->toContain('InScope'); + expect($names)->not->toContain('OutOfScope'); // the orWhere branch must stay org-scoped + expect($names)->toHaveCount(1); +}); + +it('does not leak out-of-scope rows through a grouped orWhere', function () { + $names = OrganizationNodeable::where(fn ($q) => $q->where('age', 10)->orWhere('age', 999)) + ->get()->pluck('name')->all(); + + expect($names)->toContain('InScope'); + expect($names)->not->toContain('OutOfScope'); +}); From a71f2ec53c81a0803a360585f6e17cd21db70422 Mon Sep 17 00:00:00 2001 From: Emre Akay Date: Fri, 3 Jul 2026 17:33:27 +0300 Subject: [PATCH 03/20] fix(faz1): non-breaking bug fixes + regression tests - F1 updateWith/deleteWithAAuthOrganizationNode: bind to real OrganizationService signatures (persist node changes then recompute subtree paths; pass model not id), wrapped in a transaction - F2 Role::permissions(): scope to this role (stop all-roles leak) - F3 getAssignedUserCountAttribute: distinct users, not pivot rows - F4 syncPermissionsOfRole: wrap detach+attach in a transaction - F5 empty accessible-node set now returns ZERO rows (fail closed) via a single empty-guarded builder; organizationNodes()/getAccessibleOrganizationNodes() delegate to it (kills the N+1 and the whole-table over-exposure) - F6 pgsql seed: advance the organization_nodes sequence (not scopes twice) - F7 cache invalidation now uses the configured cache.store in all sites Co-Authored-By: Claude Fable 5 --- .../2021_10_18_142336_seed_initial_data.php | 8 +- src/AAuth.php | 143 +++++------------- src/Models/Role.php | 40 ++--- src/Observers/RoleObserver.php | 10 +- src/Observers/RolePermissionObserver.php | 6 +- src/Services/RolePermissionService.php | 103 +++---------- src/Traits/AAuthOrganizationNode.php | 79 +++++----- tests/Unit/V2/Faz1FixesTest.php | 48 ++++++ 8 files changed, 172 insertions(+), 265 deletions(-) create mode 100644 tests/Unit/V2/Faz1FixesTest.php diff --git a/database/migrations/2021_10_18_142336_seed_initial_data.php b/database/migrations/2021_10_18_142336_seed_initial_data.php index 05ec114..36639ae 100644 --- a/database/migrations/2021_10_18_142336_seed_initial_data.php +++ b/database/migrations/2021_10_18_142336_seed_initial_data.php @@ -14,7 +14,7 @@ class SeedInitialData extends Migration */ public function up() { - $organizationScope = new OrganizationScope(); + $organizationScope = new OrganizationScope; $organizationScope->id = 1; $organizationScope->name = 'Root Scope'; $organizationScope->level = 1; @@ -28,7 +28,7 @@ public function up() "); } - $on = new OrganizationNode(); + $on = new OrganizationNode; $on->id = 1; $on->organization_scope_id = 1; $on->name = 'Root Node'; @@ -37,8 +37,8 @@ public function up() if (config('database.default') == 'pgsql') { DB::select(" - SELECT setval(pg_get_serial_sequence('organization_scopes', 'id'), coalesce(max(id)+1, 1), false) - FROM organization_scopes; + SELECT setval(pg_get_serial_sequence('organization_nodes', 'id'), coalesce(max(id)+1, 1), false) + FROM organization_nodes; "); } } diff --git a/src/AAuth.php b/src/AAuth.php index 5a624b2..bbb6711 100755 --- a/src/AAuth.php +++ b/src/AAuth.php @@ -37,12 +37,12 @@ class AAuth */ public function __construct(?AAuthUserContract $user, ?int $roleId) { - throw_unless($user, new AuthenticationException()); - throw_unless($roleId, new MissingRoleException()); + throw_unless($user, new AuthenticationException); + throw_unless($roleId, new MissingRoleException); throw_if( $user->roles()->where('roles.id', '=', $roleId)->count() < 1, - new UserHasNoAssignedRoleException() + new UserHasNoAssignedRoleException ); $this->user = $user; @@ -51,7 +51,7 @@ public function __construct(?AAuthUserContract $user, ?int $roleId) ? $this->getCachedRole($roleId) : $this->loadRole($roleId); - throw_unless($this->role, new MissingRoleException()); + throw_unless($this->role, new MissingRoleException); $this->organizationNodeIds = DB::table('user_role_organization_node') ->where('user_id', '=', $user->id) @@ -61,9 +61,6 @@ public function __construct(?AAuthUserContract $user, ?int $roleId) $this->loadAndCacheContext(); } - /** - * @return Role|null - */ public function currentRole(): ?Role { // todo unit test @@ -83,7 +80,6 @@ public function switchableRoles(): array|Collection|\Illuminate\Support\Collecti } /** - * @param int $userId * @return array|Collection|\Illuminate\Support\Collection */ public static function switchableRolesStatic(int $userId): array|Collection|\Illuminate\Support\Collection @@ -97,8 +93,6 @@ public static function switchableRolesStatic(int $userId): array|Collection|\Ill /** * Role's all permissions - * - * @return array */ public function permissions(): array { @@ -110,8 +104,6 @@ public function permissions(): array /** * Get organization permissions for current role * Defensive: supports both old 'type' column and new organization_scope_id approach - * - * @return array */ public function organizationPermissions(): array { @@ -132,8 +124,6 @@ public function organizationPermissions(): array /** * Get system permissions for current role * Defensive: supports both old 'type' column and new organization_scope_id approach - * - * @return array */ public function systemPermissions(): array { @@ -153,8 +143,6 @@ public function systemPermissions(): array /** * Check if type column exists in roles table (for backward compatibility) - * - * @return bool */ protected function hasRolesTypeColumn(): bool { @@ -169,9 +157,6 @@ protected function hasRolesTypeColumn(): bool /** * Get cached role with permissions and ABAC rules - * - * @param int $roleId - * @return Role|null */ protected function getCachedRole(int $roleId): ?Role { @@ -190,9 +175,6 @@ protected function getCachedRole(int $roleId): ?Role /** * Load role from database with permissions and ABAC rules - * - * @param int $roleId - * @return Role|null */ protected function loadRole(int $roleId): ?Role { @@ -233,11 +215,6 @@ protected function loadSwitchableRoles(): Collection ->select('roles.id', 'name')->get(); } - /** - * @param string $permission - * @param mixed ...$arguments - * @return bool - */ public function can(string $permission, mixed ...$arguments): bool { $cacheKey = $this->getPermissionCacheKey($permission, $arguments); @@ -274,9 +251,6 @@ public function can(string $permission, mixed ...$arguments): bool return $result; } - /** - * @return bool - */ public function isSuperAdmin(): bool { $context = $this->getAuthContext(); @@ -340,7 +314,7 @@ public function clearContext(): void protected function getPermissionCacheKey(string $permission, array $arguments): string { - return $permission . ':' . md5(json_encode($arguments) ?: ''); + return $permission.':'.md5(json_encode($arguments) ?: ''); } protected function validateParameters(array $roleParameters, array $arguments): bool @@ -374,6 +348,7 @@ protected function validateParameters(array $roleParameters, array $arguments): /** * @deprecated Use getAuthContext() instead + * * @return array */ protected function getPermissionsWithParameters(): array @@ -383,11 +358,6 @@ protected function getPermissionsWithParameters(): array return $context['permissions']; } - /** - * @param string $permission - * @param string $message - * @return void - */ public function passOrAbort(string $permission, string $message = 'No Permission'): void { // todo mesaj dil dosyasından gelecek. @@ -400,55 +370,35 @@ public function passOrAbort(string $permission, string $message = 'No Permission * Returns user's current role's authorized organization nodes * if model type is given, returns only this model typed nodes. * - * @param bool $includeRootNode - * @param string|null $modelType * @return \Illuminate\Support\Collection * * @throws Throwable */ public function organizationNodes(bool $includeRootNode = false, ?string $modelType = null): \Illuminate\Support\Collection { - // todo scope eklenecek. $scopeLevel $scopeName - // todo depth ler eklenecek $maxDepthFromRoot $minDepthFromRoot - - return OrganizationNode::where(function ($query) use ($includeRootNode) { - foreach ($this->organizationNodeIds as $organizationNodeId) { - $rootNode = OrganizationNode::find($organizationNodeId); - throw_unless($rootNode, new InvalidOrganizationNodeException()); - - /** - * @phpstan-ignore-next-line - */ - $query->orWhere('path', 'like', $rootNode->path . '/%'); - - if ($includeRootNode) { - /** - * @phpstan-ignore-next-line - */ - $query->orWhere('path', $rootNode->path); - } - - } - }) - ->when($modelType !== null, function ($query) use ($modelType) { - return $query->where('model_type', '=', $modelType); - })->get(); + // Delegate to the single empty-guarded builder: fixes the N+1 (per-id find()) + // and the empty-scope over-exposure (a role with no nodes must return ZERO rows). + return $this->organizationNodesQuery($includeRootNode, $modelType)->get(); } /** - * @param bool $includeRootNode - * @param string|null $modelType - * @return OrganizationNode|Builder * @throws Throwable */ public function organizationNodesQuery(bool $includeRootNode = false, ?string $modelType = null): OrganizationNode|Builder { $rootNodes = OrganizationNode::whereIn('id', $this->organizationNodeIds)->get(); - throw_unless($rootNodes->isNotEmpty(), new InvalidOrganizationNodeException()); return OrganizationNode::where(function ($query) use ($rootNodes, $includeRootNode) { + // Fail closed: a role with no accessible nodes matches ZERO rows, + // never falling through to an unconstrained (whole-table) result. + if ($rootNodes->isEmpty()) { + $query->whereRaw('1 = 0'); + + return; + } + foreach ($rootNodes as $rootNode) { - $query->orWhere('path', 'like', $rootNode->path . '/%'); + $query->orWhere('path', 'like', $rootNode->path.'/%'); if ($includeRootNode) { $query->orWhere('path', '=', $rootNode->path); @@ -462,9 +412,6 @@ public function organizationNodesQuery(bool $includeRootNode = false, ?string $m /** * checks if current role authorized to access given node id * - * @param int $nodeId - * @param string|null $modelType - * @return OrganizationNode * * @throws InvalidOrganizationNodeException|Throwable */ @@ -483,12 +430,9 @@ public function organizationNode(int $nodeId, ?string $modelType = null): Organi return OrganizationNode::findOrFail($nodeId)->first(); } */ - throw new InvalidOrganizationNodeException(); + throw new InvalidOrganizationNodeException; } - /** - * @return array|null - */ public function organizationNodeIds(): ?array { return $this->organizationNodeIds; @@ -497,13 +441,13 @@ public function organizationNodeIds(): ?array /** * Get accessible organization nodes with depth and scope filtering * - * @param int|null $minDepthFromRoot Minimum depth from root (inclusive, 0-based) - * @param int|null $maxDepthFromRoot Maximum depth from root (inclusive, 0-based) - * @param string|null $scopeName Organization scope name filter - * @param int|null $scopeLevel Organization scope level filter - * @param bool $includeRootNode Include root nodes in results - * @param string|null $modelType Filter by model type - * @return \Illuminate\Support\Collection + * @param int|null $minDepthFromRoot Minimum depth from root (inclusive, 0-based) + * @param int|null $maxDepthFromRoot Maximum depth from root (inclusive, 0-based) + * @param string|null $scopeName Organization scope name filter + * @param int|null $scopeLevel Organization scope level filter + * @param bool $includeRootNode Include root nodes in results + * @param string|null $modelType Filter by model type + * * @throws Throwable */ public function getAccessibleOrganizationNodes( @@ -514,21 +458,21 @@ public function getAccessibleOrganizationNodes( bool $includeRootNode = false, ?string $modelType = null ): \Illuminate\Support\Collection { - return OrganizationNode::where(function ($query) use ($includeRootNode) { - foreach ($this->organizationNodeIds as $organizationNodeId) { - $rootNode = OrganizationNode::find($organizationNodeId); - throw_unless($rootNode, new InvalidOrganizationNodeException()); + $rootNodes = OrganizationNode::whereIn('id', $this->organizationNodeIds)->get(); - /** - * @phpstan-ignore-next-line - */ - $query->orWhere('path', 'like', $rootNode->path . '/%'); + return OrganizationNode::where(function ($query) use ($rootNodes, $includeRootNode) { + // Fail closed on an empty accessible-node set (no whole-table leak). + if ($rootNodes->isEmpty()) { + $query->whereRaw('1 = 0'); + + return; + } + + foreach ($rootNodes as $rootNode) { + $query->orWhere('path', 'like', $rootNode->path.'/%'); if ($includeRootNode) { - /** - * @phpstan-ignore-next-line - */ - $query->orWhere('path', $rootNode->path); + $query->orWhere('path', '=', $rootNode->path); } } }) @@ -568,25 +512,18 @@ public function getAccessibleOrganizationNodes( * Checks if tree has given child * No permission check. * - * @param int $rootNodeId - * @param int $childNodeId - * @return bool * * @throws Throwable */ public function descendant(int $rootNodeId, int $childNodeId): bool { $subTreeRootNode = OrganizationNode::find($rootNodeId); - throw_unless($subTreeRootNode, new InvalidOrganizationNodeException()); + throw_unless($subTreeRootNode, new InvalidOrganizationNodeException); - return OrganizationNode::where('path', 'like', $subTreeRootNode->path . '%') + return OrganizationNode::where('path', 'like', $subTreeRootNode->path.'%') ->where('id', '=', $childNodeId)->exists(); } - /** - * @param string $modelType - * @return array|null - */ public function ABACRules(string $modelType): ?array { return RoleModelAbacRule::where('role_id', '=', $this->role->id) diff --git a/src/Models/Role.php b/src/Models/Role.php index eaa41b6..f6943c0 100644 --- a/src/Models/Role.php +++ b/src/Models/Role.php @@ -2,11 +2,13 @@ namespace AuroraWebSoftware\AAuth\Models; +use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\Pivot; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; @@ -27,27 +29,24 @@ */ class Role extends Model { - /** @use \Illuminate\Database\Eloquent\Factories\HasFactory<\Illuminate\Database\Eloquent\Factories\Factory<\AuroraWebSoftware\AAuth\Models\Role>> */ + /** @use HasFactory> */ use HasFactory; protected $fillable = ['organization_scope_id', 'type', 'name', 'status']; /** * Get permissions as array (legacy method - backward compatible) - * - * @return array */ public function permissions(): array { - return $this - ->join('role_permission', 'role_permission.role_id', '=', 'roles.id') + return RolePermission::where('role_id', $this->id) ->pluck('permission')->toArray(); } /** * Get role permissions as HasMany relationship (v2) * - * @return \Illuminate\Database\Eloquent\Relations\HasMany<\AuroraWebSoftware\AAuth\Models\RolePermission, \AuroraWebSoftware\AAuth\Models\Role> + * @return HasMany */ public function rolePermissions(): HasMany { @@ -55,7 +54,7 @@ public function rolePermissions(): HasMany } /** - * @return \Illuminate\Database\Eloquent\Relations\HasMany<\AuroraWebSoftware\AAuth\Models\RoleModelAbacRule, \AuroraWebSoftware\AAuth\Models\Role> + * @return HasMany */ public function abacRules(): HasMany { @@ -63,7 +62,7 @@ public function abacRules(): HasMany } /** - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo<\AuroraWebSoftware\AAuth\Models\OrganizationScope, \AuroraWebSoftware\AAuth\Models\Role> + * @return BelongsTo */ public function organization_scope(): BelongsTo { @@ -71,7 +70,7 @@ public function organization_scope(): BelongsTo } /** - * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany<\AuroraWebSoftware\AAuth\Models\OrganizationNode, \AuroraWebSoftware\AAuth\Models\Role, \Illuminate\Database\Eloquent\Relations\Pivot> + * @return BelongsToMany */ public function organization_nodes(): BelongsToMany { @@ -80,10 +79,6 @@ public function organization_nodes(): BelongsToMany /** * Give a permission to this role - * - * @param string $permission - * @param array|null $parameters - * @return \AuroraWebSoftware\AAuth\Models\RolePermission */ public function givePermission(string $permission, ?array $parameters = null): RolePermission { @@ -100,9 +95,6 @@ public function givePermission(string $permission, ?array $parameters = null): R /** * Remove a permission from this role - * - * @param string $permission - * @return bool */ public function removePermission(string $permission): bool { @@ -114,8 +106,7 @@ public function removePermission(string $permission): bool /** * Sync permissions for this role * - * @param array $permissions Array of permission strings or ['permission' => 'params'] pairs - * @return void + * @param array $permissions Array of permission strings or ['permission' => 'params'] pairs */ public function syncPermissions(array $permissions): void { @@ -136,9 +127,6 @@ public function syncPermissions(array $permissions): void /** * Check if role has a specific permission - * - * @param string $permission - * @return bool */ public function hasPermission(string $permission): bool { @@ -147,19 +135,13 @@ public function hasPermission(string $permission): bool ->exists(); } - /** - * @return int - */ public function getAssignedUserCountAttribute(): int { - // new attribute syntax + // Distinct users assigned this role (a user may hold it at multiple nodes). return DB::table('user_role_organization_node') - ->where('role_id', $this->id)->groupBy('user_id')->count(); + ->where('role_id', $this->id)->distinct('user_id')->count('user_id'); } - /** - * @return bool - */ public function getDeletableAttribute(): bool { // new attribute syntax diff --git a/src/Observers/RoleObserver.php b/src/Observers/RoleObserver.php index d80354e..8909329 100644 --- a/src/Observers/RoleObserver.php +++ b/src/Observers/RoleObserver.php @@ -35,10 +35,12 @@ protected function clearRoleCache(Role $role): void } $prefix = config('aauth-advanced.cache.prefix', 'aauth'); + $store = config('aauth-advanced.cache.store'); + $cache = $store ? Cache::store($store) : Cache::store(); - Cache::forget("{$prefix}:role:{$role->id}"); - Cache::forget("{$prefix}:role:{$role->id}:permissions"); - Cache::forget("{$prefix}:role:{$role->id}:abac_rules"); + $cache->forget("{$prefix}:role:{$role->id}"); + $cache->forget("{$prefix}:role:{$role->id}:permissions"); + $cache->forget("{$prefix}:role:{$role->id}:abac_rules"); // Clear switchable roles cache for all users with this role $userIds = DB::table('user_role_organization_node') @@ -47,7 +49,7 @@ protected function clearRoleCache(Role $role): void ->pluck('user_id'); foreach ($userIds as $userId) { - Cache::forget("{$prefix}:user:{$userId}:switchable_roles"); + $cache->forget("{$prefix}:user:{$userId}:switchable_roles"); } } } diff --git a/src/Observers/RolePermissionObserver.php b/src/Observers/RolePermissionObserver.php index e47a480..c064219 100644 --- a/src/Observers/RolePermissionObserver.php +++ b/src/Observers/RolePermissionObserver.php @@ -58,8 +58,10 @@ protected function clearPermissionCache(RolePermission $permission): void } $prefix = config('aauth-advanced.cache.prefix', 'aauth'); + $store = config('aauth-advanced.cache.store'); + $cache = $store ? Cache::store($store) : Cache::store(); - Cache::forget("{$prefix}:role:{$permission->role_id}"); - Cache::forget("{$prefix}:role:{$permission->role_id}:permissions"); + $cache->forget("{$prefix}:role:{$permission->role_id}"); + $cache->forget("{$prefix}:role:{$permission->role_id}:permissions"); } } diff --git a/src/Services/RolePermissionService.php b/src/Services/RolePermissionService.php index 5fa385f..10eeb21 100644 --- a/src/Services/RolePermissionService.php +++ b/src/Services/RolePermissionService.php @@ -27,9 +27,6 @@ class RolePermissionService /** * Creates a Perm. with given array * - * @param array $role - * @param bool $withValidation - * @return Role * * @throws ValidationException */ @@ -50,11 +47,6 @@ public function createRole(array $role, bool $withValidation = true): Role /** * Updates a Perm. - * - * @param array $role - * @param int $id - * @param bool $withValidation - * @return Role|null */ public function updateRole(array $role, int $id, bool $withValidation = true): ?Role { @@ -74,9 +66,6 @@ public function updateRole(array $role, int $id, bool $withValidation = true): ? /** * deletes the role. - * - * @param int $id - * @return bool|null */ public function deleteRole(int $id): ?bool { @@ -85,9 +74,6 @@ public function deleteRole(int $id): ?bool /** * activates the roles - * - * @param int $roleId - * @return bool */ public function activateRole(int $roleId): bool { @@ -99,9 +85,6 @@ public function activateRole(int $roleId): bool /** * deactivates the roles - * - * @param int $roleId - * @return bool */ public function deactivateRole(int $roleId): bool { @@ -111,11 +94,6 @@ public function deactivateRole(int $roleId): bool return $roleId->save(); } - /** - * @param string|array $permissionOrPermissions - * @param int $roleId - * @return bool - */ public function attachPermissionToRole(string|array $permissionOrPermissions, int $roleId): bool { $roleId = Role::find($roleId)->id; @@ -135,11 +113,6 @@ public function attachPermissionToRole(string|array $permissionOrPermissions, in return true; } - /** - * @param string|array $permissions - * @param int $roleId - * @return bool - */ public function detachPermissionFromRole(string|array $permissions, int $roleId): bool { $roleId = Role::find($roleId)->id; @@ -159,10 +132,6 @@ public function detachPermissionFromRole(string|array $permissions, int $roleId) return true; } - /** - * @param int $roleId - * @return bool - */ public function detachAllPermissionsFromRole(int $roleId): bool { $roleId = Role::find($roleId)->id; @@ -174,28 +143,23 @@ public function detachAllPermissionsFromRole(int $roleId): bool } /** - * @param array $permissions - * @param int $roleId - * @return bool - * * @throws Throwable */ public function syncPermissionsOfRole(array $permissions, int $roleId): bool { - // todo need refactor $role = Role::find($roleId); - throw_if($role == null, new InvalidRoleException()); + throw_if($role == null, new InvalidRoleException); - $detached = $this->detachAllPermissionsFromRole($roleId); - $attached = $this->attachPermissionToRole($permissions, $roleId); + return DB::transaction(function () use ($permissions, $roleId) { + $detached = $this->detachAllPermissionsFromRole($roleId); + $attached = $this->attachPermissionToRole($permissions, $roleId); - return $attached && $detached; + return $attached && $detached; + }); } /** - * @param int $userId * @param array $roleIdOrIds - * @return array * * @throws Throwable */ @@ -209,7 +173,7 @@ public function attachSystemRoleToUser(array|int $roleIdOrIds, int $userId): arr } throw_unless(User::whereId($userId) - ->exists(), new InvalidUserException()); + ->exists(), new InvalidUserException); $roleQuery = Role::whereId($roleIdOrIds); if ($this->hasRolesTypeColumn()) { @@ -217,7 +181,7 @@ public function attachSystemRoleToUser(array|int $roleIdOrIds, int $userId): arr } else { $roleQuery->whereNull('organization_scope_id'); } - throw_unless($roleQuery->exists(), new InvalidRoleException()); + throw_unless($roleQuery->exists(), new InvalidRoleException); $result = User::find($userId)->system_roles()->sync($roleIdOrIds, false); @@ -228,9 +192,7 @@ public function attachSystemRoleToUser(array|int $roleIdOrIds, int $userId): arr } /** - * @param int $userId * @param int $roleIdOrIds - * @return int * * @throws Throwable */ @@ -242,7 +204,7 @@ public function detachSystemRoleFromUser(array|int $roleIdOrIds, int $userId): i } throw_unless(User::whereId($userId) - ->exists(), new InvalidUserException()); + ->exists(), new InvalidUserException); // Defensive: check if old 'type' column exists $roleQuery = Role::whereId($roleIdOrIds); @@ -251,7 +213,7 @@ public function detachSystemRoleFromUser(array|int $roleIdOrIds, int $userId): i } else { $roleQuery->whereNull('organization_scope_id'); } - throw_unless($roleQuery->exists(), new InvalidRoleException()); + throw_unless($roleQuery->exists(), new InvalidRoleException); $result = User::find($userId)->system_roles()->detach($roleIdOrIds); @@ -260,11 +222,6 @@ public function detachSystemRoleFromUser(array|int $roleIdOrIds, int $userId): i return $result; } - /** - * @param int $userId - * @param array $roleIds - * @return array - */ public function syncUserSystemRoles(int $userId, array $roleIds): array { // todo @@ -279,10 +236,6 @@ public function syncUserSystemRoles(int $userId, array $roleIds): array /** * it makes organization insert and return the pivot table id's * - * @param int $userId - * @param int $roleId - * @param int $organizationNodeId - * @return bool * * @throws Throwable */ @@ -290,7 +243,7 @@ public function attachOrganizationRoleToUser(int $organizationNodeId, int $roleI { // todo burası belki user trait'i ile yapılabilir ? throw_unless(User::whereId($userId) - ->exists(), new InvalidUserException()); + ->exists(), new InvalidUserException); $roleQuery = Role::whereId($roleId); if ($this->hasRolesTypeColumn()) { @@ -298,10 +251,10 @@ public function attachOrganizationRoleToUser(int $organizationNodeId, int $roleI } else { $roleQuery->whereNotNull('organization_scope_id'); } - throw_unless($roleQuery->exists(), new InvalidRoleException()); + throw_unless($roleQuery->exists(), new InvalidRoleException); throw_unless(OrganizationNode::whereId($organizationNodeId) - ->exists(), new InvalidOrganizationNodeException()); + ->exists(), new InvalidOrganizationNodeException); $result = DB::table('user_role_organization_node') ->updateOrInsert([ @@ -325,18 +278,13 @@ public function attachOrganizationRoleToUser(int $organizationNodeId, int $roleI * This historic-order method continues to work and emits no * runtime notice; it will be removed in the next major release. * - * @param int $userId - * @param int $roleId - * @param int $organizationNodeId - * @return int - * * @throws Throwable */ public function detachOrganizationRoleFromUser(int $userId, int $roleId, int $organizationNodeId): int { // todo burası belki user trait'i ile yapılabilir ? throw_unless(User::whereId($userId) - ->exists(), new InvalidUserException()); + ->exists(), new InvalidUserException); $roleQuery = Role::whereId($roleId); if ($this->hasRolesTypeColumn()) { @@ -344,10 +292,10 @@ public function detachOrganizationRoleFromUser(int $userId, int $roleId, int $or } else { $roleQuery->whereNotNull('organization_scope_id'); } - throw_unless($roleQuery->exists(), new InvalidRoleException()); + throw_unless($roleQuery->exists(), new InvalidRoleException); throw_unless(OrganizationNode::whereId($organizationNodeId) - ->exists(), new InvalidOrganizationNodeException()); + ->exists(), new InvalidOrganizationNodeException); $result = DB::table('user_role_organization_node') ->where([ @@ -370,16 +318,12 @@ public function detachOrganizationRoleFromUser(int $userId, int $roleId, int $or * which keeps the historic, inconsistent parameter order for backward * compatibility and will be removed in the next major release. * - * @param int $organizationNodeId - * @param int $roleId - * @param int $userId - * @return int * * @throws Throwable */ public function detachOrganizationRoleFromUserBy(int $organizationNodeId, int $roleId, int $userId): int { - throw_unless(User::whereId($userId)->exists(), new InvalidUserException()); + throw_unless(User::whereId($userId)->exists(), new InvalidUserException); $roleQuery = Role::whereId($roleId); if ($this->hasRolesTypeColumn()) { @@ -387,11 +331,11 @@ public function detachOrganizationRoleFromUserBy(int $organizationNodeId, int $r } else { $roleQuery->whereNotNull('organization_scope_id'); } - throw_unless($roleQuery->exists(), new InvalidRoleException()); + throw_unless($roleQuery->exists(), new InvalidRoleException); throw_unless( OrganizationNode::whereId($organizationNodeId)->exists(), - new InvalidOrganizationNodeException() + new InvalidOrganizationNodeException ); $result = DB::table('user_role_organization_node') @@ -409,8 +353,6 @@ public function detachOrganizationRoleFromUserBy(int $organizationNodeId, int $r /** * Check if type column exists in roles table (for backward compatibility) - * - * @return bool */ protected function hasRolesTypeColumn(): bool { @@ -425,9 +367,6 @@ protected function hasRolesTypeColumn(): bool /** * Clear user's role-related cache - * - * @param int $userId - * @return void */ protected function clearUserRoleCache(int $userId): void { @@ -436,7 +375,9 @@ protected function clearUserRoleCache(int $userId): void } $prefix = config('aauth-advanced.cache.prefix', 'aauth'); + $store = config('aauth-advanced.cache.store'); + $cache = $store ? Cache::store($store) : Cache::store(); - Cache::forget("{$prefix}:user:{$userId}:switchable_roles"); + $cache->forget("{$prefix}:user:{$userId}:switchable_roles"); } } diff --git a/src/Traits/AAuthOrganizationNode.php b/src/Traits/AAuthOrganizationNode.php index b6fbcd6..d01e72d 100644 --- a/src/Traits/AAuthOrganizationNode.php +++ b/src/Traits/AAuthOrganizationNode.php @@ -10,6 +10,7 @@ use AuroraWebSoftware\AAuth\Services\OrganizationService; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; +use Illuminate\Support\Facades\DB; use Throwable; /** @@ -17,12 +18,9 @@ */ trait AAuthOrganizationNode { - /** - * @return void - */ public static function bootAAuthOrganizationNode(): void { - static::addGlobalScope(new AAuthOrganizationNodeScope()); + static::addGlobalScope(new AAuthOrganizationNodeScope); /* static::saving(function ($model) { $model->slug = $model->generateSlug($model->title); @@ -30,17 +28,11 @@ public static function bootAAuthOrganizationNode(): void */ } - /** - * @return mixed - */ public function allWithoutAAuthOrganizationNodeScope(): mixed { return self::withoutGlobalScopes()->all(); } - /** - * @return OrganizationNode|Builder|Model|null - */ public function relatedAAuthOrganizationNode(): Model|OrganizationNode|Builder|null { return OrganizationNode::whereModelId($this->getModelId()) @@ -54,18 +46,18 @@ public function relatedAAuthOrganizationNode(): Model|OrganizationNode|Builder|n public static function createWithAAuthOrganizationNode(array $modelCreateData, int $parentOrganizationNodeId, int $organizationScopeId) { // todo di - $organizationService = new OrganizationService(); + $organizationService = new OrganizationService; // todo yetki kontrolü ? serviste mi olmalı? // gerekli validationlar, organization scope validationları vs. // commit rollback $parentOrganizationNode = OrganizationNode::find($parentOrganizationNodeId); - throw_if($parentOrganizationNode == null, new InvalidOrganizationNodeException()); + throw_if($parentOrganizationNode == null, new InvalidOrganizationNodeException); $organizationScope = OrganizationScope::find($organizationScopeId); - throw_if($organizationScope == null, new InvalidOrganizationScopeException()); + throw_if($organizationScope == null, new InvalidOrganizationScopeException); $createdModel = self::create($modelCreateData); @@ -87,58 +79,61 @@ public static function createWithAAuthOrganizationNode(array $modelCreateData, i public static function updateWithAAuthOrganizationNode(int $modelId, int $nodeId, array $modelUpdateData, int $parentOrganizationNodeId, int $organizationScopeId) { - $organizationService = new OrganizationService(); + $organizationService = new OrganizationService; $parentOrganizationNode = OrganizationNode::find($parentOrganizationNodeId); - throw_if($parentOrganizationNode == null, new InvalidOrganizationNodeException()); + throw_if($parentOrganizationNode == null, new InvalidOrganizationNodeException); $organizationScope = OrganizationScope::find($organizationScopeId); - throw_if($organizationScope == null, new InvalidOrganizationScopeException()); - - - $modelInfo = self::find($modelId); + throw_if($organizationScope == null, new InvalidOrganizationScopeException); - $updatedModel = $modelInfo->update($modelUpdateData); + return DB::transaction(function () use ($modelId, $nodeId, $modelUpdateData, $parentOrganizationNode, $organizationScope, $organizationService) { + $modelInfo = self::findOrFail($modelId); + $updatedModel = $modelInfo->update($modelUpdateData); + // Persist the node's new parent/scope/name, THEN recompute the whole + // subtree path (updateNodePathsRecursively only recomputes; it does not + // persist field changes on its own). + $node = OrganizationNode::findOrFail($nodeId); + $node->update([ + 'name' => $modelInfo->getModelName(), + 'organization_scope_id' => $organizationScope->id, + 'parent_id' => $parentOrganizationNode->id, + ]); + $organizationService->updateNodePathsRecursively($node, false); - $OrgNodeUpdateData = [ - 'name' => $modelInfo->getModelName(), - 'organization_scope_id' => $organizationScope->id, - 'parent_id' => $parentOrganizationNode->id, - 'model_type' => self::getModelType(), - 'model_id' => $modelId, - ]; - $updateON = $organizationService->updateOrganizationNodesRecursively($OrgNodeUpdateData, $nodeId); - - return $updatedModel; + return $updatedModel; + }); } /** - * @param int $modelId * @return bool + * * @throws Throwable */ public static function deleteWithAAuthOrganizationNode(int $modelId) { - $organizationService = new OrganizationService(); - - $organizationNode = OrganizationNode::where('model_id', $modelId)->first(); + $organizationService = new OrganizationService; + return DB::transaction(function () use ($modelId, $organizationService) { + $organizationNode = OrganizationNode::where('model_id', $modelId) + ->where('model_type', self::getModelType()) + ->first(); - throw_if($organizationNode == null, new InvalidOrganizationNodeException()); + throw_if($organizationNode == null, new InvalidOrganizationNodeException); + $modelInfo = self::findOrFail($modelId); + $modelInfo->delete(); - $modelInfo = self::findOrFail($modelId); + // Pass the model (not its id) — deleteOrganizationNodesRecursively expects + // an OrganizationNode; participate in the outer transaction. + $organizationService->deleteOrganizationNodesRecursively($organizationNode, false); - $deleteModel = $modelInfo->delete($modelInfo); - - - $deleteON = $organizationService->deleteOrganizationNodesRecursively($organizationNode->id); - - return true; + return true; + }); } } diff --git a/tests/Unit/V2/Faz1FixesTest.php b/tests/Unit/V2/Faz1FixesTest.php new file mode 100644 index 0000000..af630ff --- /dev/null +++ b/tests/Unit/V2/Faz1FixesTest.php @@ -0,0 +1,48 @@ +run(); +}); + +// F2 — Role::permissions() must return ONLY this role's permissions (no all-roles leak). +it('F2: Role::permissions() does not leak other roles permissions', function () { + $a = Role::create(['type' => 'system', 'name' => 'A', 'status' => 'active']); + $b = Role::create(['type' => 'system', 'name' => 'B', 'status' => 'active']); + $a->givePermission('perm.a'); + $b->givePermission('perm.b'); + + expect($a->permissions())->toBe(['perm.a']); + expect($b->permissions())->toBe(['perm.b']); +}); + +// F3 — getAssignedUserCount counts DISTINCT users, not pivot rows. +it('F3: assigned_user_count is distinct users, not pivot rows', function () { + $role = Role::create(['type' => 'organization', 'organization_scope_id' => 1, 'name' => 'C', 'status' => 'active']); + + // Same user assigned at two different nodes → two pivot rows, one distinct user. + DB::table('user_role_organization_node')->insert([ + ['user_id' => 1, 'role_id' => $role->id, 'organization_node_id' => 1], + ['user_id' => 1, 'role_id' => $role->id, 'organization_node_id' => 2], + ]); + + expect($role->assigned_user_count)->toBe(1); + expect($role->deletable)->toBeFalse(); +}); + +// F5 — a role with no accessible org nodes returns ZERO nodes (fail closed), never the whole table, never a throw. +it('F5: empty accessible-node set returns zero rows (fail closed)', function () { + // role 1 is a system role assigned with no organization_node_id. + $this->app->singleton('aauth', fn ($app) => new AAuth(User::find(1), 1)); + + expect(app('aauth')->organizationNodes())->toHaveCount(0); + expect(app('aauth')->organizationNodesQuery()->get())->toHaveCount(0); + expect(app('aauth')->getAccessibleOrganizationNodes())->toHaveCount(0); +}); From 5280b903cf47dba79f379c272c022b5035c3eb9d Mon Sep 17 00:00:00 2001 From: Emre Akay Date: Fri, 3 Jul 2026 17:52:52 +0300 Subject: [PATCH 04/20] feat(faz2): secure-by-default fixes (no config flags) + tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the confirmed HIGH findings directly (documented behaviour changes): - S1 parametric permissions fail CLOSED (positional): no-arg on a parametric perm denies; missing/non-numeric-vs-int/unknown → deny; add string exact-match; keep positional matching (middleware/Blade/Gate pass args positionally) - S2 deactivated (status='passive') roles are rejected in the constructor and hidden from switchable-role listings — deactivateRole is now an effective kill switch - S3 Gate::before defers to a host Policy when one handles the ability for the model (no IDOR via name-collision policy shadowing) - S4 createWith/updateWith/deleteWithAAuthOrganizationNode authorize the target node against the active role's subtree (context-bound; seeders/ console/queue without a context are skipped) - S5 Role type/organization_scope_id are no longer mass-assignable; the service sets them explicitly (no org→system escalation via raw input) - S6 descendant() anchored to the '/' separator (no sibling-prefix match) - S7 ABAC rule 'attribute' validated as a bare column identifier (no SQL/column injection from stored rules) - S8 passOrAbort() gains an additive optional $arguments to reach can() - S9 attachOrganizationRoleToUser authorizes the target node (context-bound) Co-Authored-By: Claude Fable 5 --- src/AAuth.php | 54 ++++++++++----- src/AAuthServiceProvider.php | 21 +++--- src/Contracts/AAuthUserContract.php | 5 +- src/Events/PermissionAddedEvent.php | 3 +- src/Events/PermissionRemovedEvent.php | 3 +- src/Events/PermissionUpdatedEvent.php | 3 +- src/Events/RoleAssignedEvent.php | 3 +- src/Events/RoleCreatedEvent.php | 3 +- src/Events/RoleDeletedEvent.php | 3 +- src/Events/RoleRemovedEvent.php | 3 +- src/Events/RoleSwitchedEvent.php | 3 +- src/Events/RoleUpdatedEvent.php | 3 +- src/Exceptions/AuthorizationException.php | 2 - .../InvalidLocationScopesException.php | 1 - .../InvalidOrganizationNodeException.php | 1 - .../InvalidOrganizationScopeException.php | 3 - src/Exceptions/InvalidRoleException.php | 3 - src/Exceptions/InvalidRoleTypeException.php | 3 - src/Exceptions/InvalidUserException.php | 3 - src/Exceptions/MissingRoleException.php | 3 - .../OrganizationNodeAuthException.php | 5 -- .../OrganizationScopesMismatchException.php | 1 - .../UserHasNoAssignedRoleException.php | 3 - src/Facades/AAuth.php | 2 + .../Middleware/AAuthOrganizationScope.php | 6 -- src/Http/Middleware/AAuthPermission.php | 7 -- src/Http/Middleware/AAuthRole.php | 6 -- .../Requests/StoreOrganizationNodeRequest.php | 4 -- .../StoreOrganizationScopeRequest.php | 3 - src/Http/Requests/StoreRoleRequest.php | 4 -- .../UpdateOrganizationNodeRequest.php | 4 -- .../UpdateOrganizationScopeRequest.php | 4 -- src/Http/Requests/UpdateRoleRequest.php | 6 +- src/Models/OrganizationNode.php | 17 +++-- src/Models/OrganizationScope.php | 15 ++--- src/Models/Role.php | 6 +- src/Models/RoleModelAbacRule.php | 3 +- src/Models/RolePermission.php | 2 +- src/Models/User.php | 18 +++-- src/Scopes/AAuthABACModelScope.php | 23 ++----- src/Scopes/AAuthOrganizationNodeScope.php | 19 ++---- src/Services/OrganizationService.php | 42 ++---------- src/Services/RolePermissionService.php | 30 ++++++++- src/Traits/AAuthABACModel.php | 5 +- src/Traits/AAuthOrganizationNode.php | 29 ++++++++- src/Traits/AAuthUser.php | 18 ----- src/Utils/ABACUtil.php | 11 ++-- tests/Unit/V2/Faz2SecurityTest.php | 65 +++++++++++++++++++ 48 files changed, 236 insertions(+), 248 deletions(-) create mode 100644 tests/Unit/V2/Faz2SecurityTest.php diff --git a/src/AAuth.php b/src/AAuth.php index bbb6711..be2ee32 100755 --- a/src/AAuth.php +++ b/src/AAuth.php @@ -40,8 +40,10 @@ public function __construct(?AAuthUserContract $user, ?int $roleId) throw_unless($user, new AuthenticationException); throw_unless($roleId, new MissingRoleException); + // Only an ACTIVE assigned role may be selected — deactivateRole() is an + // effective kill switch on the very next request. throw_if( - $user->roles()->where('roles.id', '=', $roleId)->count() < 1, + $user->roles()->where('roles.id', '=', $roleId)->where('status', '=', 'active')->count() < 1, new UserHasNoAssignedRoleException ); @@ -86,6 +88,7 @@ public static function switchableRolesStatic(int $userId): array|Collection|\Ill { // todo test'i yazılacak return Role::where('uro.user_id', '=', $userId) + ->where('status', '=', 'active') ->leftJoin('user_role_organization_node as uro', 'uro.role_id', '=', 'roles.id') ->distinct() ->select('roles.id', 'name')->get(); @@ -210,6 +213,7 @@ protected function loadSwitchableRoles(): Collection { // @phpstan-ignore-next-line return Role::where('uro.user_id', '=', $this->user->id) + ->where('status', '=', 'active') ->leftJoin('user_role_organization_node as uro', 'uro.role_id', '=', 'roles.id') ->distinct() ->select('roles.id', 'name')->get(); @@ -238,9 +242,12 @@ public function can(string $permission, mixed ...$arguments): bool } if (empty($arguments)) { - $this->requestCache[$cacheKey] = true; + // Fail closed: a permission that declares parameter constraints cannot be + // granted without the runtime value(s) needed to check them. + $result = empty($context['permissions'][$permission]); + $this->requestCache[$cacheKey] = $result; - return true; + return $result; } $roleParameters = $context['permissions'][$permission]; @@ -319,17 +326,20 @@ protected function getPermissionCacheKey(string $permission, array $arguments): protected function validateParameters(array $roleParameters, array $arguments): bool { - foreach ($roleParameters as $paramName => $roleValue) { - $argIndex = array_search($paramName, array_keys($roleParameters)); - - if (! isset($arguments[$argIndex])) { - continue; + // Positional matching: the Nth declared parameter is checked against the Nth + // runtime argument. Fail closed on any missing/mismatched/unknown constraint. + $argIndex = 0; + foreach ($roleParameters as $roleValue) { + if (! array_key_exists($argIndex, $arguments)) { + return false; } $runtimeValue = $arguments[$argIndex]; + $argIndex++; - if (is_int($roleValue) && is_numeric($runtimeValue)) { - if ($runtimeValue > $roleValue) { + if (is_int($roleValue)) { + // Numeric upper bound; a non-numeric runtime value cannot satisfy it. + if (! is_numeric($runtimeValue) || $runtimeValue > $roleValue) { return false; } } elseif (is_array($roleValue)) { @@ -340,6 +350,12 @@ protected function validateParameters(array $roleParameters, array $arguments): if ((bool) $runtimeValue !== $roleValue) { return false; } + } elseif (is_string($roleValue)) { + if ((string) $runtimeValue !== $roleValue) { + return false; + } + } else { + return false; } } @@ -358,10 +374,12 @@ protected function getPermissionsWithParameters(): array return $context['permissions']; } - public function passOrAbort(string $permission, string $message = 'No Permission'): void + /** + * @param array $arguments parametric permission values, forwarded to can() + */ + public function passOrAbort(string $permission, string $message = 'No Permission', array $arguments = []): void { - // todo mesaj dil dosyasından gelecek. - if (! $this->can($permission)) { + if (! $this->can($permission, ...$arguments)) { abort(ResponseAlias::HTTP_UNAUTHORIZED, $message); } } @@ -382,6 +400,8 @@ public function organizationNodes(bool $includeRootNode = false, ?string $modelT } /** + * @return Builder|OrganizationNode + * * @throws Throwable */ public function organizationNodesQuery(bool $includeRootNode = false, ?string $modelType = null): OrganizationNode|Builder @@ -520,8 +540,12 @@ public function descendant(int $rootNodeId, int $childNodeId): bool $subTreeRootNode = OrganizationNode::find($rootNodeId); throw_unless($subTreeRootNode, new InvalidOrganizationNodeException); - return OrganizationNode::where('path', 'like', $subTreeRootNode->path.'%') - ->where('id', '=', $childNodeId)->exists(); + // Anchored to the '/' separator so root '1' does not match sibling '10'/'1/3'→'1/30'. + return OrganizationNode::where('id', '=', $childNodeId) + ->where(function ($query) use ($subTreeRootNode) { + $query->where('path', '=', $subTreeRootNode->path) + ->orWhere('path', 'like', $subTreeRootNode->path.'/%'); + })->exists(); } public function ABACRules(string $modelType): ?array diff --git a/src/AAuthServiceProvider.php b/src/AAuthServiceProvider.php index bb1e112..b7960d5 100644 --- a/src/AAuthServiceProvider.php +++ b/src/AAuthServiceProvider.php @@ -19,21 +19,13 @@ class AAuthServiceProvider extends PackageServiceProvider { - /** - * @param Package $package - * @return void - */ public function configurePackage(Package $package): void { $package ->name('aauth') - ->hasConfigFile(['aauth', 'aauth-advanced']) - ; + ->hasConfigFile(['aauth', 'aauth-advanced']); } - /** - * @return void - */ public function boot(): void { parent::boot(); @@ -77,6 +69,17 @@ public function boot(): void Gate::before(function ($user, $ability, $arguments = []) { try { + // Defer to a host-app Policy when one handles this ability for the given + // model, so object-level checks (e.g. ownership) still run. A name-only + // AAuth permission must NOT silently shadow a registered policy (IDOR). + $model = is_array($arguments) ? ($arguments[0] ?? null) : $arguments; + if (is_object($model) || (is_string($model) && class_exists($model))) { + $policy = Gate::getPolicyFor($model); + if ($policy !== null && method_exists($policy, $ability)) { + return null; + } + } + /** @var AAuth $aauth */ $aauth = app('aauth'); diff --git a/src/Contracts/AAuthUserContract.php b/src/Contracts/AAuthUserContract.php index 563a98b..c6653d3 100644 --- a/src/Contracts/AAuthUserContract.php +++ b/src/Contracts/AAuthUserContract.php @@ -2,7 +2,10 @@ namespace AuroraWebSoftware\AAuth\Contracts; +use AuroraWebSoftware\AAuth\Models\Role; +use AuroraWebSoftware\AAuth\Models\User; use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\Pivot; /** * @property-read int $id @@ -10,7 +13,7 @@ interface AAuthUserContract { /** - * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany<\AuroraWebSoftware\AAuth\Models\Role, \AuroraWebSoftware\AAuth\Models\User, \Illuminate\Database\Eloquent\Relations\Pivot> + * @return BelongsToMany */ public function roles(): BelongsToMany; } diff --git a/src/Events/PermissionAddedEvent.php b/src/Events/PermissionAddedEvent.php index 9a631f3..2e2dc24 100644 --- a/src/Events/PermissionAddedEvent.php +++ b/src/Events/PermissionAddedEvent.php @@ -15,6 +15,5 @@ public function __construct( public Role $role, public string $permission, public ?array $parameters = null - ) { - } + ) {} } diff --git a/src/Events/PermissionRemovedEvent.php b/src/Events/PermissionRemovedEvent.php index ef1faaa..9ee9981 100644 --- a/src/Events/PermissionRemovedEvent.php +++ b/src/Events/PermissionRemovedEvent.php @@ -14,6 +14,5 @@ class PermissionRemovedEvent public function __construct( public Role $role, public string $permission - ) { - } + ) {} } diff --git a/src/Events/PermissionUpdatedEvent.php b/src/Events/PermissionUpdatedEvent.php index c3b71b4..845c187 100644 --- a/src/Events/PermissionUpdatedEvent.php +++ b/src/Events/PermissionUpdatedEvent.php @@ -16,6 +16,5 @@ public function __construct( public string $permission, public ?array $parameters = null, public ?array $oldParameters = null - ) { - } + ) {} } diff --git a/src/Events/RoleAssignedEvent.php b/src/Events/RoleAssignedEvent.php index 0b340e3..3fcc4ec 100644 --- a/src/Events/RoleAssignedEvent.php +++ b/src/Events/RoleAssignedEvent.php @@ -16,6 +16,5 @@ public function __construct( public int $userId, public Role $role, public ?OrganizationNode $organizationNode = null - ) { - } + ) {} } diff --git a/src/Events/RoleCreatedEvent.php b/src/Events/RoleCreatedEvent.php index c47a5e0..2e00ea5 100644 --- a/src/Events/RoleCreatedEvent.php +++ b/src/Events/RoleCreatedEvent.php @@ -13,6 +13,5 @@ class RoleCreatedEvent public function __construct( public Role $role - ) { - } + ) {} } diff --git a/src/Events/RoleDeletedEvent.php b/src/Events/RoleDeletedEvent.php index 28823ec..e068e59 100644 --- a/src/Events/RoleDeletedEvent.php +++ b/src/Events/RoleDeletedEvent.php @@ -13,6 +13,5 @@ class RoleDeletedEvent public function __construct( public Role $role - ) { - } + ) {} } diff --git a/src/Events/RoleRemovedEvent.php b/src/Events/RoleRemovedEvent.php index 1179e2f..286b18c 100644 --- a/src/Events/RoleRemovedEvent.php +++ b/src/Events/RoleRemovedEvent.php @@ -16,6 +16,5 @@ public function __construct( public int $userId, public Role $role, public ?OrganizationNode $organizationNode = null - ) { - } + ) {} } diff --git a/src/Events/RoleSwitchedEvent.php b/src/Events/RoleSwitchedEvent.php index c309514..2c8512d 100644 --- a/src/Events/RoleSwitchedEvent.php +++ b/src/Events/RoleSwitchedEvent.php @@ -17,6 +17,5 @@ public function __construct( public Role $newRole, public ?Role $oldRole = null, public ?OrganizationNode $organizationNode = null - ) { - } + ) {} } diff --git a/src/Events/RoleUpdatedEvent.php b/src/Events/RoleUpdatedEvent.php index 190e019..207dc77 100644 --- a/src/Events/RoleUpdatedEvent.php +++ b/src/Events/RoleUpdatedEvent.php @@ -13,6 +13,5 @@ class RoleUpdatedEvent public function __construct( public Role $role - ) { - } + ) {} } diff --git a/src/Exceptions/AuthorizationException.php b/src/Exceptions/AuthorizationException.php index 944f608..d61daee 100644 --- a/src/Exceptions/AuthorizationException.php +++ b/src/Exceptions/AuthorizationException.php @@ -23,8 +23,6 @@ class AuthorizationException extends Exception /** * Create a new authentication exception. * - * @param string $message - * @param array $guards * @param string|null $redirectTo * @return void */ diff --git a/src/Exceptions/InvalidLocationScopesException.php b/src/Exceptions/InvalidLocationScopesException.php index b00b707..8f8f074 100644 --- a/src/Exceptions/InvalidLocationScopesException.php +++ b/src/Exceptions/InvalidLocationScopesException.php @@ -22,7 +22,6 @@ public function report() /** * Render the exception into an HTTP response. * - * @param Request $request * @return Response */ public function render(Request $request): Response|bool diff --git a/src/Exceptions/InvalidOrganizationNodeException.php b/src/Exceptions/InvalidOrganizationNodeException.php index e176621..6564f99 100644 --- a/src/Exceptions/InvalidOrganizationNodeException.php +++ b/src/Exceptions/InvalidOrganizationNodeException.php @@ -22,7 +22,6 @@ public function report() /** * Render the exception into an HTTP response. * - * @param Request $request * @return Response */ public function render(Request $request): Response|bool diff --git a/src/Exceptions/InvalidOrganizationScopeException.php b/src/Exceptions/InvalidOrganizationScopeException.php index d0a9e08..b1d8fa0 100644 --- a/src/Exceptions/InvalidOrganizationScopeException.php +++ b/src/Exceptions/InvalidOrganizationScopeException.php @@ -21,9 +21,6 @@ public function report() /** * Render the exception into an HTTP response. - * - * @param Request $request - * @return Response|bool */ public function render(Request $request): Response|bool { diff --git a/src/Exceptions/InvalidRoleException.php b/src/Exceptions/InvalidRoleException.php index a2309af..47deff8 100644 --- a/src/Exceptions/InvalidRoleException.php +++ b/src/Exceptions/InvalidRoleException.php @@ -20,9 +20,6 @@ public function report() /** * Render the exception into an HTTP response. - * - * @param Request $request - * @return Response|bool */ public function render(Request $request): Response|bool { diff --git a/src/Exceptions/InvalidRoleTypeException.php b/src/Exceptions/InvalidRoleTypeException.php index 490f071..f65cd7a 100644 --- a/src/Exceptions/InvalidRoleTypeException.php +++ b/src/Exceptions/InvalidRoleTypeException.php @@ -20,9 +20,6 @@ public function report() /** * Render the exception into an HTTP response. - * - * @param Request $request - * @return Response|bool */ public function render(Request $request): Response|bool { diff --git a/src/Exceptions/InvalidUserException.php b/src/Exceptions/InvalidUserException.php index 353ced9..e52dd6a 100644 --- a/src/Exceptions/InvalidUserException.php +++ b/src/Exceptions/InvalidUserException.php @@ -20,9 +20,6 @@ public function report() /** * Render the exception into an HTTP response. - * - * @param Request $request - * @return Response|bool */ public function render(Request $request): Response|bool { diff --git a/src/Exceptions/MissingRoleException.php b/src/Exceptions/MissingRoleException.php index 2913e9c..0c3a93b 100644 --- a/src/Exceptions/MissingRoleException.php +++ b/src/Exceptions/MissingRoleException.php @@ -24,7 +24,6 @@ class MissingRoleException extends Exception * Create a new authentication exception. * * @param string|null $message - * @param array $guards * @param string|null $redirectTo * @return void */ @@ -39,8 +38,6 @@ public function __construct($message = null, array $guards = [], $redirectTo = n /** * Get default translated message - * - * @return string */ protected function getDefaultMessage(): string { diff --git a/src/Exceptions/OrganizationNodeAuthException.php b/src/Exceptions/OrganizationNodeAuthException.php index 276294c..61cd44e 100644 --- a/src/Exceptions/OrganizationNodeAuthException.php +++ b/src/Exceptions/OrganizationNodeAuthException.php @@ -10,8 +10,6 @@ class OrganizationNodeAuthException extends Exception { /** * Report the exception. - * - * @return void */ public function report(): void { @@ -20,9 +18,6 @@ public function report(): void /** * Render the exception into an HTTP response. - * - * @param Request $request - * @return Response|bool */ public function render(Request $request): Response|bool { diff --git a/src/Exceptions/OrganizationScopesMismatchException.php b/src/Exceptions/OrganizationScopesMismatchException.php index f5eb7d6..fc95b7b 100644 --- a/src/Exceptions/OrganizationScopesMismatchException.php +++ b/src/Exceptions/OrganizationScopesMismatchException.php @@ -22,7 +22,6 @@ public function report() /** * Render the exception into an HTTP response. * - * @param Request $request * @return Response */ public function render(Request $request): Response|bool diff --git a/src/Exceptions/UserHasNoAssignedRoleException.php b/src/Exceptions/UserHasNoAssignedRoleException.php index 9b81948..cdae782 100644 --- a/src/Exceptions/UserHasNoAssignedRoleException.php +++ b/src/Exceptions/UserHasNoAssignedRoleException.php @@ -20,9 +20,6 @@ public function report() /** * Render the exception into an HTTP response. - * - * @param Request $request - * @return Response|bool */ public function render(Request $request): Response|bool { diff --git a/src/Facades/AAuth.php b/src/Facades/AAuth.php index a312e3b..a01eb24 100644 --- a/src/Facades/AAuth.php +++ b/src/Facades/AAuth.php @@ -9,6 +9,7 @@ * AAuth Facade * * @see AuroraWebSoftware + * * @static switchableRoles(): array|Collection|\Illuminate\Support\Collection * @static permissions(): \Illuminate\Support\Collection|array * @static organizationPermissions(): \Illuminate\Support\Collection|array @@ -18,6 +19,7 @@ * @static passOrAbort(string $string, string $message = 'No Permission') * * @method static organizationNodes(bool $includeRootNode = false, ?string $modelType = null): \Illuminate\Support\Collection + * * @static organizationNode(int $nodeId, ?string $modelType = null): OrganizationNode|array|Collection|Model * @static descendant(int $rootNodeId, int $childNodeId): bool * @static switchableRolesStatic(int $userId): array|Collection|\Illuminate\Support\Collection diff --git a/src/Http/Middleware/AAuthOrganizationScope.php b/src/Http/Middleware/AAuthOrganizationScope.php index ac01b25..80a4ebd 100644 --- a/src/Http/Middleware/AAuthOrganizationScope.php +++ b/src/Http/Middleware/AAuthOrganizationScope.php @@ -8,12 +8,6 @@ class AAuthOrganizationScope { - /** - * @param Request $request - * @param Closure $next - * @param int|null $organizationScopeId - * @return Response - */ public function handle(Request $request, Closure $next, ?int $organizationScopeId = null): Response { try { diff --git a/src/Http/Middleware/AAuthPermission.php b/src/Http/Middleware/AAuthPermission.php index 3cd1d54..a630be1 100644 --- a/src/Http/Middleware/AAuthPermission.php +++ b/src/Http/Middleware/AAuthPermission.php @@ -8,13 +8,6 @@ class AAuthPermission { - /** - * @param Request $request - * @param Closure $next - * @param string $permission - * @param mixed ...$parameters - * @return Response - */ public function handle(Request $request, Closure $next, string $permission, mixed ...$parameters): Response { try { diff --git a/src/Http/Middleware/AAuthRole.php b/src/Http/Middleware/AAuthRole.php index ac4edab..bb18d46 100644 --- a/src/Http/Middleware/AAuthRole.php +++ b/src/Http/Middleware/AAuthRole.php @@ -8,12 +8,6 @@ class AAuthRole { - /** - * @param Request $request - * @param Closure $next - * @param string $role - * @return Response - */ public function handle(Request $request, Closure $next, string $role): Response { try { diff --git a/src/Http/Requests/StoreOrganizationNodeRequest.php b/src/Http/Requests/StoreOrganizationNodeRequest.php index 05161ab..7a0e373 100644 --- a/src/Http/Requests/StoreOrganizationNodeRequest.php +++ b/src/Http/Requests/StoreOrganizationNodeRequest.php @@ -14,8 +14,6 @@ class StoreOrganizationNodeRequest extends FormRequest /** * Determine if the user is authorized to make this request. - * - * @return bool */ public function authorize(): bool { @@ -24,8 +22,6 @@ public function authorize(): bool /** * Get the validation rules that apply to the request. - * - * @return array */ public function rules(): array { diff --git a/src/Http/Requests/StoreOrganizationScopeRequest.php b/src/Http/Requests/StoreOrganizationScopeRequest.php index ca30a23..ef5f3a4 100644 --- a/src/Http/Requests/StoreOrganizationScopeRequest.php +++ b/src/Http/Requests/StoreOrganizationScopeRequest.php @@ -11,9 +11,6 @@ class StoreOrganizationScopeRequest extends FormRequest 'level' => [], ]; - /** - * @return array - */ public static function getRules(): array { return self::$rules; diff --git a/src/Http/Requests/StoreRoleRequest.php b/src/Http/Requests/StoreRoleRequest.php index 507b012..f796ddb 100644 --- a/src/Http/Requests/StoreRoleRequest.php +++ b/src/Http/Requests/StoreRoleRequest.php @@ -13,8 +13,6 @@ class StoreRoleRequest extends FormRequest /** * Determine if the user is authorized to make this request. - * - * @return bool */ public function authorize(): bool { @@ -23,8 +21,6 @@ public function authorize(): bool /** * Get the validation rules that apply to the request. - * - * @return array */ public function rules(): array { diff --git a/src/Http/Requests/UpdateOrganizationNodeRequest.php b/src/Http/Requests/UpdateOrganizationNodeRequest.php index 615efc8..1fc0ed9 100644 --- a/src/Http/Requests/UpdateOrganizationNodeRequest.php +++ b/src/Http/Requests/UpdateOrganizationNodeRequest.php @@ -13,8 +13,6 @@ class UpdateOrganizationNodeRequest extends FormRequest /** * Determine if the user is authorized to make this request. - * - * @return bool */ public function authorize(): bool { @@ -23,8 +21,6 @@ public function authorize(): bool /** * Get the validation rules that apply to the request. - * - * @return array */ public function rules(): array { diff --git a/src/Http/Requests/UpdateOrganizationScopeRequest.php b/src/Http/Requests/UpdateOrganizationScopeRequest.php index e9ee3fe..ca1d730 100644 --- a/src/Http/Requests/UpdateOrganizationScopeRequest.php +++ b/src/Http/Requests/UpdateOrganizationScopeRequest.php @@ -13,8 +13,6 @@ class UpdateOrganizationScopeRequest extends FormRequest /** * Determine if the user is authorized to make this request. - * - * @return bool */ public function authorize(): bool { @@ -23,8 +21,6 @@ public function authorize(): bool /** * Get the validation rules that apply to the request. - * - * @return array */ public function rules(): array { diff --git a/src/Http/Requests/UpdateRoleRequest.php b/src/Http/Requests/UpdateRoleRequest.php index a3a9878..d926455 100644 --- a/src/Http/Requests/UpdateRoleRequest.php +++ b/src/Http/Requests/UpdateRoleRequest.php @@ -15,8 +15,6 @@ class UpdateRoleRequest extends FormRequest /** * Determine if the user is authorized to make this request. - * - * @return bool */ public function authorize(): bool { @@ -25,13 +23,11 @@ public function authorize(): bool /** * Get the validation rules that apply to the request. - * - * @return array */ public function rules(): array { $rules = self::$rules; - $rules['name'][] = 'unique:roles,name,' . $this->route('role'); + $rules['name'][] = 'unique:roles,name,'.$this->route('role'); return $rules; } diff --git a/src/Models/OrganizationNode.php b/src/Models/OrganizationNode.php index cee99b5..f60ee07 100644 --- a/src/Models/OrganizationNode.php +++ b/src/Models/OrganizationNode.php @@ -4,6 +4,7 @@ use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -41,17 +42,18 @@ * @method static Builder|OrganizationNode wherePath($value) * @method static Builder|OrganizationNode whereUpdatedAt($value) * @method static OrganizationNode find($value) + * * @mixin OrganizationNode */ class OrganizationNode extends Model { - /** @use \Illuminate\Database\Eloquent\Factories\HasFactory<\Illuminate\Database\Eloquent\Factories\Factory<\AuroraWebSoftware\AAuth\Models\OrganizationNode>> */ + /** @use HasFactory> */ use HasFactory; protected $fillable = ['organization_scope_id', 'name', 'model_type', 'model_id', 'path', 'parent_id']; /** - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo<\AuroraWebSoftware\AAuth\Models\OrganizationScope, static> + * @return BelongsTo */ public function organization_scope(): BelongsTo { @@ -61,17 +63,14 @@ public function organization_scope(): BelongsTo public function getAssignedNodeCountAttribute(): int { - //todo new attribute syntax + // todo new attribute syntax return DB::table('user_role_organization_node') ->where('organization_node_id', $this->id)->count(); } - /** - * @return bool - */ public function getDeletableAttribute(): bool { - //todo new attrbiute syntax + // todo new attrbiute syntax if (OrganizationNode::whereParentId($this->id)->exists()) { return false; } @@ -81,7 +80,7 @@ public function getDeletableAttribute(): bool /** * @return Collection - * todo daha güzel fonksiyon ismi bulunmalı + * todo daha güzel fonksiyon ismi bulunmalı */ public function availableScopes(): Collection { @@ -107,7 +106,7 @@ public function breadCrumbs(): \Illuminate\Support\Collection } /** - * @return \Illuminate\Database\Eloquent\Relations\MorphTo<\Illuminate\Database\Eloquent\Model, \AuroraWebSoftware\AAuth\Models\OrganizationNode> + * @return MorphTo */ public function relatedModel(): MorphTo { diff --git a/src/Models/OrganizationScope.php b/src/Models/OrganizationScope.php index ba954ba..a456f68 100644 --- a/src/Models/OrganizationScope.php +++ b/src/Models/OrganizationScope.php @@ -3,6 +3,8 @@ namespace AuroraWebSoftware\AAuth\Models; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Collection; +use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -18,6 +20,7 @@ * @property string $status * @property Carbon|null $created_at * @property Carbon|null $updated_at + * * @mixin \Eloquent * * @method static Builder|OrganizationScope whereLevel($value) @@ -26,30 +29,24 @@ * @property-read bool $is_active * @property-read int $node_count * @property-read string $status_color - * @property-read \Illuminate\Database\Eloquent\Collection|\AuroraWebSoftware\AAuth\Models\OrganizationNode[] $organization_nodes + * @property-read Collection|OrganizationNode[] $organization_nodes * @property-read int|null $organization_nodes_count */ class OrganizationScope extends Model { - /** @use \Illuminate\Database\Eloquent\Factories\HasFactory<\Illuminate\Database\Eloquent\Factories\Factory<\AuroraWebSoftware\AAuth\Models\OrganizationScope>> */ + /** @use HasFactory> */ use HasFactory; protected $primaryKey = 'id'; protected $fillable = ['name', 'level', 'status']; - /** - * @return int - */ public function getNodeCountAttribute(): int { // todo new attribute syntax return OrganizationNode::whereOrganizationScopeId($this->id)->count(); } - /** - * @return bool - */ public function getDeletableAttribute(): bool { // todo new attribute syntax @@ -57,7 +54,7 @@ public function getDeletableAttribute(): bool } /** - * @return \Illuminate\Database\Eloquent\Relations\HasMany<\AuroraWebSoftware\AAuth\Models\OrganizationNode, \AuroraWebSoftware\AAuth\Models\OrganizationScope> + * @return HasMany */ public function organization_nodes(): HasMany { diff --git a/src/Models/Role.php b/src/Models/Role.php index f6943c0..d9f1a97 100644 --- a/src/Models/Role.php +++ b/src/Models/Role.php @@ -32,7 +32,11 @@ class Role extends Model /** @use HasFactory> */ use HasFactory; - protected $fillable = ['organization_scope_id', 'type', 'name', 'status']; + // Privilege-defining columns (type, organization_scope_id) are intentionally NOT + // mass-assignable — they may only be set explicitly by the authorized service + // methods, so forwarding raw request input cannot escalate an org role to a + // system role or move it into another scope. + protected $fillable = ['name', 'status']; /** * Get permissions as array (legacy method - backward compatible) diff --git a/src/Models/RoleModelAbacRule.php b/src/Models/RoleModelAbacRule.php index 99ed4b5..8e627b2 100644 --- a/src/Models/RoleModelAbacRule.php +++ b/src/Models/RoleModelAbacRule.php @@ -2,6 +2,7 @@ namespace AuroraWebSoftware\AAuth\Models; +use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Carbon; @@ -22,7 +23,7 @@ */ class RoleModelAbacRule extends Model { - /** @use \Illuminate\Database\Eloquent\Factories\HasFactory<\Illuminate\Database\Eloquent\Factories\Factory<\AuroraWebSoftware\AAuth\Models\RoleModelAbacRule>> */ + /** @use HasFactory> */ use HasFactory; protected $casts = [ diff --git a/src/Models/RolePermission.php b/src/Models/RolePermission.php index 497c075..fc288cc 100644 --- a/src/Models/RolePermission.php +++ b/src/Models/RolePermission.php @@ -50,7 +50,7 @@ class RolePermission extends Model /** * Get the role that owns this permission. * - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo<\AuroraWebSoftware\AAuth\Models\Role, \AuroraWebSoftware\AAuth\Models\RolePermission> + * @return BelongsTo */ public function role(): BelongsTo { diff --git a/src/Models/User.php b/src/Models/User.php index 3072321..8e9ef2d 100644 --- a/src/Models/User.php +++ b/src/Models/User.php @@ -3,8 +3,10 @@ namespace AuroraWebSoftware\AAuth\Models; use AuroraWebSoftware\AAuth\Contracts\AAuthUserContract; +use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\Pivot; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Illuminate\Support\Carbon; @@ -36,8 +38,9 @@ */ class User extends Authenticatable implements AAuthUserContract { - /** @use \Illuminate\Database\Eloquent\Factories\HasFactory<\Illuminate\Database\Eloquent\Factories\Factory<\AuroraWebSoftware\AAuth\Models\User>> */ + /** @use HasFactory> */ use HasFactory; + use Notifiable; protected $fillable = [ @@ -62,7 +65,7 @@ class User extends Authenticatable implements AAuthUserContract ]; /** - * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany<\AuroraWebSoftware\AAuth\Models\Role, \AuroraWebSoftware\AAuth\Models\User, \Illuminate\Database\Eloquent\Relations\Pivot> + * @return BelongsToMany */ public function roles(): BelongsToMany { @@ -83,6 +86,7 @@ public function rolesWithOrganizationNodes(): Collection $role = Role::find($rolesWithOrganizationNode->role_id); /** * @var Role $role + * * @phpstan-ignore-next-line */ $role->organizationNode = OrganizationNode::find($rolesWithOrganizationNode->organization_node_id); @@ -94,7 +98,7 @@ public function rolesWithOrganizationNodes(): Collection } /** - * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany<\AuroraWebSoftware\AAuth\Models\Role, \AuroraWebSoftware\AAuth\Models\User, \Illuminate\Database\Eloquent\Relations\Pivot> + * @return BelongsToMany */ public function system_roles(): BelongsToMany { @@ -103,7 +107,7 @@ public function system_roles(): BelongsToMany } /** - * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany<\AuroraWebSoftware\AAuth\Models\Role, \AuroraWebSoftware\AAuth\Models\User, \Illuminate\Database\Eloquent\Relations\Pivot> + * @return BelongsToMany */ public function organization_roles(): BelongsToMany { @@ -111,18 +115,12 @@ public function organization_roles(): BelongsToMany ->where('type', 'organization'); } - /** - * @return int - */ public function getAssignedUserCountAttribute(): int { return DB::table('user_role_organization_node') ->where('user_id', $this->id)->count(); } - /** - * @return bool - */ public function getDeletableAttribute(): bool { // todo new syntax diff --git a/src/Scopes/AAuthABACModelScope.php b/src/Scopes/AAuthABACModelScope.php index db4dd2a..74210f9 100644 --- a/src/Scopes/AAuthABACModelScope.php +++ b/src/Scopes/AAuthABACModelScope.php @@ -16,11 +16,8 @@ class AAuthABACModelScope implements Scope { /** - * @param Builder $builder - * @param Model $model - * @param mixed $rules - * @param string $parentOperator - * @return void + * @param Builder $builder + * * @throws Exception */ public function apply(Builder $builder, Model $model, mixed $rules = false, string $parentOperator = '&&'): void @@ -31,6 +28,7 @@ public function apply(Builder $builder, Model $model, mixed $rules = false, stri * * PHPStan analysis does not return any errors, but it underlines the ABACRules method because it somehow * does not see it, even though it is defined in the facade. + * * @phpstan-ignore-next-line */ $rules = AAuth::ABACRules($model::getModelType()) ?? []; @@ -47,7 +45,7 @@ public function apply(Builder $builder, Model $model, mixed $rules = false, stri $this->apply($query, $model, $rules); }); } else { - $logicalOperators = ["&&","||"]; + $logicalOperators = ['&&', '||']; foreach ($rules as $rule) { $firstKey = array_key_first($rule); @@ -65,13 +63,8 @@ public function apply(Builder $builder, Model $model, mixed $rules = false, stri /** * Apply logical operator (&& or ||) to the query builder. * - * @param Builder $builder - * @param array $abacRule - * @param Model $model - * @param string $logicalOperator - * @param string $parentOperator + * @param Builder $builder * - * @return void * @throws Exception */ protected function applyLogicalOperator(Builder $builder, array $abacRule, Model $model, string $logicalOperator, string $parentOperator): void @@ -86,11 +79,7 @@ protected function applyLogicalOperator(Builder $builder, array $abacRule, Model /** * Apply conditional operator to the query builder. * - * @param Builder $builder - * @param array $rule - * @param string $parentOperator - * - * @return void + * @param Builder $builder */ protected function applyConditionalOperator(Builder $builder, array $rule, string $parentOperator): void { diff --git a/src/Scopes/AAuthOrganizationNodeScope.php b/src/Scopes/AAuthOrganizationNodeScope.php index 7a08ee2..c2018fd 100644 --- a/src/Scopes/AAuthOrganizationNodeScope.php +++ b/src/Scopes/AAuthOrganizationNodeScope.php @@ -5,13 +5,12 @@ use AuroraWebSoftware\AAuth\Facades\AAuth; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Scope; -class AAuthOrganizationNodeScope implements \Illuminate\Database\Eloquent\Scope +class AAuthOrganizationNodeScope implements Scope { /** - * @param Builder $builder - * @param Model $model - * @return void + * @param Builder $builder */ public function apply(Builder $builder, Model $model): void { @@ -30,9 +29,6 @@ public function apply(Builder $builder, Model $model): void /** * Get the table name from the query's "from" clause - * - * @param mixed $from - * @return string */ protected function getFromTableName(mixed $from): string { @@ -41,14 +37,10 @@ protected function getFromTableName(mixed $from): string /** * Prefix the where column with the table name if needed - * - * @param array $where - * @param string $from - * @return array */ protected function prefixWhereColumn(array $where, string $from): array { - if (isset($where['column']) && ! str_contains($where['column'], $from . '.')) { + if (isset($where['column']) && ! str_contains($where['column'], $from.'.')) { $where['column'] = sprintf('%s.%s', $from, $where['column']); } @@ -57,9 +49,6 @@ protected function prefixWhereColumn(array $where, string $from): array /** * Get the select columns for the query (only selects fields from the left table) - * - * @param string $from - * @return array */ protected function getSelectColumns(string $from): array { diff --git a/src/Services/OrganizationService.php b/src/Services/OrganizationService.php index 889a922..fc76a16 100644 --- a/src/Services/OrganizationService.php +++ b/src/Services/OrganizationService.php @@ -21,9 +21,6 @@ class OrganizationService /** * Creates an org. scope with given array * - * @param array $organizationScope - * @param bool $withValidation - * @return OrganizationScope * * @throws ValidationException */ @@ -44,10 +41,6 @@ public function createOrganizationScope(array $organizationScope, bool $withVali /** * Updates a Perm. * - * @param array $organizationScope - * @param int $id - * @param bool $withValidation - * @return ?OrganizationScope * * @throws ValidationException */ @@ -69,9 +62,6 @@ public function updateOrganizationScope(array $organizationScope, int $id, bool /** * deletes perm. - * - * @param int $id - * @return bool|null */ public function deleteOrganizationScope(int $id): ?bool { @@ -82,8 +72,6 @@ public function deleteOrganizationScope(int $id): ?bool * Creates an org. node with given array * * @param array $organizationNode - * @param bool $withValidation - * @return OrganizationNode * * @throws ValidationException */ @@ -108,24 +96,19 @@ public function createOrganizationNode(array $organizationNode, bool $withValida 'name' => $organizationNode['name'] ?? null, 'model_type' => $organizationNode['model_type'] ?? null, 'model_id' => $organizationNode['model_id'] ?? null, - 'path' => $parentPath . '/?', + 'path' => $parentPath.'/?', 'parent_id' => $organizationNode['parent_id'] ?? null, ]; $created = OrganizationNode::create($attributes); // todo , can be add inside model's created event - $created->path = $parentPath . $created->id; + $created->path = $parentPath.$created->id; $created->save(); return $created; } - /** - * @param Model $model - * @param int $parentOrganizationId - * @return OrganizationNode|null - */ public function createOrganizationNodeForModel(Model $model, int $parentOrganizationId): ?OrganizationNode { return null; @@ -133,8 +116,6 @@ public function createOrganizationNodeForModel(Model $model, int $parentOrganiza /** * Return path with trailing slash (/) - * @param int|null $organizationNodeId - * @return string|null */ public function getPath(?int $organizationNodeId): ?string { @@ -142,12 +123,9 @@ public function getPath(?int $organizationNodeId): ?string return ''; } - return OrganizationNode::find($organizationNodeId)?->path . '/'; + return OrganizationNode::find($organizationNodeId)?->path.'/'; } - /** - * @param int $organizationNodeId - */ public function calculatePath(int $organizationNodeId): void { // todo @@ -166,9 +144,6 @@ public function calculatePath(int $organizationNodeId): void * - true (default): open a top-level transaction here. * - false : the caller is already managing a transaction; participate in it. * - * @param OrganizationNode $node - * @param bool|null $withDBTransaction - * @return void * * @throws \Throwable */ @@ -185,13 +160,10 @@ public function updateNodePathsRecursively(OrganizationNode $node, ?bool $withDB /** * Inner recursion for path updates. Does not manage transactions. - * - * @param OrganizationNode $node - * @return void */ protected function updateSubtreePaths(OrganizationNode $node): void { - $node->path = $this->getPath($node->parent_id) . $node->id; + $node->path = $this->getPath($node->parent_id).$node->id; $node->save(); foreach (OrganizationNode::whereParentId($node->id)->get() as $subNode) { @@ -207,9 +179,6 @@ protected function updateSubtreePaths(OrganizationNode $node): void * and re-throw the original exception. Previous versions silently swallowed * exceptions. * - * @param OrganizationNode $node - * @param bool|null $withDBTransaction - * @return void * * @throws \Throwable */ @@ -226,9 +195,6 @@ public function deleteOrganizationNodesRecursively(OrganizationNode $node, ?bool /** * Inner recursion for deletes. Does not manage transactions. - * - * @param OrganizationNode $node - * @return void */ protected function deleteSubtree(OrganizationNode $node): void { diff --git a/src/Services/RolePermissionService.php b/src/Services/RolePermissionService.php index 10eeb21..2b1ea12 100644 --- a/src/Services/RolePermissionService.php +++ b/src/Services/RolePermissionService.php @@ -42,7 +42,14 @@ public function createRole(array $role, bool $withValidation = true): Role } } - return Role::create($role); + $model = new Role; + $model->fill(['name' => $role['name'] ?? null, 'status' => $role['status'] ?? 'active']); + // type / organization_scope_id are set explicitly here (not mass-assignable). + $model->type = $role['type'] ?? null; + $model->organization_scope_id = $role['organization_scope_id'] ?? null; + $model->save(); + + return $model; } /** @@ -256,6 +263,10 @@ public function attachOrganizationRoleToUser(int $organizationNodeId, int $roleI throw_unless(OrganizationNode::whereId($organizationNodeId) ->exists(), new InvalidOrganizationNodeException); + // A caller may only assign a role at a node within their own accessible subtree + // (skipped when there is no active AAuth context — seeders/console/queue). + $this->assertOrganizationNodeAuthorized($organizationNodeId); + $result = DB::table('user_role_organization_node') ->updateOrInsert([ 'user_id' => $userId, @@ -368,6 +379,23 @@ protected function hasRolesTypeColumn(): bool /** * Clear user's role-related cache */ + /** + * Enforce the active role's org-subtree boundary on role assignment, but only when + * an AAuth context is resolvable (seeders/console/queue run without one and skip). + * + * @throws Throwable + */ + protected function assertOrganizationNodeAuthorized(int $nodeId): void + { + try { + $aauth = app('aauth'); + } catch (Throwable $e) { + return; + } + + $aauth->organizationNode($nodeId); + } + protected function clearUserRoleCache(int $userId): void { if (! config('aauth-advanced.cache.enabled', false)) { diff --git a/src/Traits/AAuthABACModel.php b/src/Traits/AAuthABACModel.php index 4b526c0..0676005 100644 --- a/src/Traits/AAuthABACModel.php +++ b/src/Traits/AAuthABACModel.php @@ -6,11 +6,8 @@ trait AAuthABACModel { - /** - * @return void - */ public static function bootAAuthABACModel(): void { - static::addGlobalScope(new AAuthABACModelScope()); + static::addGlobalScope(new AAuthABACModelScope); } } diff --git a/src/Traits/AAuthOrganizationNode.php b/src/Traits/AAuthOrganizationNode.php index d01e72d..2921299 100644 --- a/src/Traits/AAuthOrganizationNode.php +++ b/src/Traits/AAuthOrganizationNode.php @@ -40,6 +40,25 @@ public function relatedAAuthOrganizationNode(): Model|OrganizationNode|Builder|n ->first(); } + /** + * Enforce the active role's org-subtree boundary on writes — but only when an AAuth + * context is resolvable. Seeders, console commands and queue jobs run without an + * authenticated role and are intentionally skipped (no context = no enforcement). + * + * @throws Throwable + */ + protected static function assertOrganizationNodeAuthorized(int $nodeId): void + { + try { + $aauth = app('aauth'); + } catch (Throwable $e) { + return; + } + + // Throws InvalidOrganizationNodeException when the node is outside the subtree. + $aauth->organizationNode($nodeId); + } + /** * @throws Throwable */ @@ -48,9 +67,6 @@ public static function createWithAAuthOrganizationNode(array $modelCreateData, i // todo di $organizationService = new OrganizationService; - // todo yetki kontrolü ? serviste mi olmalı? - // gerekli validationlar, organization scope validationları vs. - // commit rollback $parentOrganizationNode = OrganizationNode::find($parentOrganizationNodeId); throw_if($parentOrganizationNode == null, new InvalidOrganizationNodeException); @@ -59,6 +75,9 @@ public static function createWithAAuthOrganizationNode(array $modelCreateData, i throw_if($organizationScope == null, new InvalidOrganizationScopeException); + // Reject grafting under a parent outside the active role's accessible subtree. + self::assertOrganizationNodeAuthorized($parentOrganizationNodeId); + $createdModel = self::create($modelCreateData); $OrgNodeCreateData = [ @@ -89,6 +108,8 @@ public static function updateWithAAuthOrganizationNode(int $modelId, int $nodeId throw_if($organizationScope == null, new InvalidOrganizationScopeException); + self::assertOrganizationNodeAuthorized($parentOrganizationNodeId); + return DB::transaction(function () use ($modelId, $nodeId, $modelUpdateData, $parentOrganizationNode, $organizationScope, $organizationService) { $modelInfo = self::findOrFail($modelId); $updatedModel = $modelInfo->update($modelUpdateData); @@ -126,6 +147,8 @@ public static function deleteWithAAuthOrganizationNode(int $modelId) throw_if($organizationNode == null, new InvalidOrganizationNodeException); + self::assertOrganizationNodeAuthorized($organizationNode->id); + $modelInfo = self::findOrFail($modelId); $modelInfo->delete(); diff --git a/src/Traits/AAuthUser.php b/src/Traits/AAuthUser.php index 7322fb9..041cd9c 100644 --- a/src/Traits/AAuthUser.php +++ b/src/Traits/AAuthUser.php @@ -11,17 +11,11 @@ trait AAuthUser { - /** - * @return BelongsToMany - */ public function roles(): BelongsToMany { return $this->belongsToMany(Role::class, 'user_role_organization_node'); } - /** - * @return Collection - */ public function rolesWithOrganizationNodes(): Collection { $rolesCollection = collect(); @@ -40,8 +34,6 @@ public function rolesWithOrganizationNodes(): Collection /** * Get system roles (global roles not tied to organization) * Defensive: supports both old 'type' column and new organization_scope_id approach - * - * @return BelongsToMany */ public function system_roles(): BelongsToMany { @@ -58,8 +50,6 @@ public function system_roles(): BelongsToMany /** * Get organization roles (roles tied to an organization scope) * Defensive: supports both old 'type' column and new organization_scope_id approach - * - * @return BelongsToMany */ public function organization_roles(): BelongsToMany { @@ -75,8 +65,6 @@ public function organization_roles(): BelongsToMany /** * Check if type column exists in roles table (for backward compatibility) - * - * @return bool */ protected function hasRolesTypeColumn(): bool { @@ -89,18 +77,12 @@ protected function hasRolesTypeColumn(): bool return $hasType; } - /** - * @return int - */ public function getAssignedUserCountAttribute(): int { return DB::table('user_role_organization_node') ->where('user_id', $this->id)->count(); } - /** - * @return bool - */ public function getDeletableAttribute(): bool { // todo new syntax diff --git a/src/Utils/ABACUtil.php b/src/Utils/ABACUtil.php index bdce8e3..f61c2e5 100644 --- a/src/Utils/ABACUtil.php +++ b/src/Utils/ABACUtil.php @@ -9,9 +9,6 @@ class ABACUtil { /** - * @param array $abacRules - * @return void - * * @throws Exception */ public static function validateAbacRuleArray(array $abacRules): void @@ -26,7 +23,10 @@ public static function validateAbacRuleArray(array $abacRules): void foreach (ABACCondition::cases() as $condition) { $validationRules[$condition->value] = ['array']; if (array_key_exists($condition->value, $abacRules)) { - $validationRules[$condition->value.'.attribute'] = ['string', 'required']; + // 'attribute' is interpolated into the column position of the query, so it + // must be a bare column identifier — reject quotes, '->', spaces, etc. + // (fail closed against SQL/column injection from stored rules). + $validationRules[$condition->value.'.attribute'] = ['string', 'required', 'regex:/^[A-Za-z_][A-Za-z0-9_]*$/']; $validationRules[$condition->value.'.value'] = ['string', 'required']; } } @@ -45,9 +45,6 @@ public static function validateAbacRuleArray(array $abacRules): void } /** - * @param string $ruleJson - * @return void - * * @throws Exception */ public static function validateAbacRuleJson(string $ruleJson): void diff --git a/tests/Unit/V2/Faz2SecurityTest.php b/tests/Unit/V2/Faz2SecurityTest.php new file mode 100644 index 0000000..290a5c5 --- /dev/null +++ b/tests/Unit/V2/Faz2SecurityTest.php @@ -0,0 +1,65 @@ +run(); +}); + +// S1 — parametric permission enforcement is FAIL-CLOSED (positional). +it('S1: parametric permission fails closed', function () { + $role = Role::create(['type' => 'system', 'name' => 'P', 'status' => 'active']); + $role->givePermission('approve', ['max_amount' => 1000]); + DB::table('user_role_organization_node')->insert(['user_id' => 1, 'role_id' => $role->id]); + $this->app->singleton('aauth', fn ($app) => new AAuth(User::find(1), $role->id)); + + $aauth = app('aauth'); + expect($aauth->can('approve', 500))->toBeTrue(); // within limit + expect($aauth->can('approve', 1500))->toBeFalse(); // over limit + expect($aauth->can('approve'))->toBeFalse(); // no arg on a parametric perm → deny + expect($aauth->can('approve', 'abc'))->toBeFalse(); // non-numeric vs int → deny +}); + +// S2 — a deactivated (passive) role can no longer be selected. +it('S2: a deactivated role is rejected on the next request', function () { + app(RolePermissionService::class)->deactivateRole(3); + + expect(fn () => new AAuth(User::find(1), 3)) + ->toThrow(UserHasNoAssignedRoleException::class); +}); + +// S5 — privilege columns are not mass-assignable. +it('S5: type/organization_scope_id cannot be mass-assigned', function () { + $raw = Role::create(['name' => 'X', 'status' => 'active', 'type' => 'system', 'organization_scope_id' => 1]); + expect($raw->type)->toBeNull(); + expect($raw->organization_scope_id)->toBeNull(); + + // The authorized service still sets them explicitly. + $viaService = app(RolePermissionService::class)->createRole( + ['name' => 'ValidRole', 'type' => 'organization', 'organization_scope_id' => 1, 'status' => 'active'] + ); + expect($viaService->type)->toBe('organization'); + expect($viaService->organization_scope_id)->toBe(1); +}); + +// S6 — descendant() is anchored to the '/' separator (no numeric-prefix sibling match). +it('S6: descendant does not match a numeric-prefix sibling', function () { + $this->app->singleton('aauth', fn ($app) => new AAuth(User::find(1), 3)); // root role + $aauth = app('aauth'); + + // Sibling-prefix node: path '1/20' shares the prefix "1/2" but is NOT under node 2 ('1/2'). + $siblingId = DB::table('organization_nodes')->insertGetId([ + 'organization_scope_id' => 2, 'name' => 'sibling', 'path' => '1/20', 'parent_id' => 1, + ]); + + expect($aauth->descendant(2, $siblingId))->toBeFalse(); // '1/20' is NOT a descendant of '1/2' + expect($aauth->descendant(2, 4))->toBeTrue(); // '1/2/4' IS a descendant of '1/2' +}); From b01f1d78cc034d2d876b61779b8998ac07c84fcb Mon Sep 17 00:00:00 2001 From: Emre Akay Date: Fri, 3 Jul 2026 17:55:29 +0300 Subject: [PATCH 05/20] ci: multi-DB testing (MariaDB + PostgreSQL) on local, GitHub & GitLab - docker-compose.yml: add PostgreSQL (:54322) alongside MariaDB (:33062), both healthchecked - composer.json: test:sqlite / test:mariadb / test:pgsql / test:all scripts - GitHub Actions: run the suite against BOTH MariaDB and PostgreSQL in every PHP/Laravel/stability cell - .gitlab-ci.yml: quality (phpstan+pint) + test:mariadb + test:postgres - README-contr.md: contributor + agent guide (setup, multi-DB tests, gates, pre-PR review agents); README.md pointer - add db-engine-specialist review agent; security-pentest gains the where/orWhere no-leak check Co-Authored-By: Claude Fable 5 --- .claude/agents/security-pentest.md | 1 + .gitlab-ci.yml | 67 +++++++++++++++++++++++++ README-contr.md | 80 ++++++++++++++++++++++++++++-- README.md | 2 + composer.json | 8 +++ docker-compose.yml | 34 +++++++++++-- 6 files changed, 182 insertions(+), 10 deletions(-) create mode 100644 .gitlab-ci.yml diff --git a/.claude/agents/security-pentest.md b/.claude/agents/security-pentest.md index 8975571..c7d7494 100644 --- a/.claude/agents/security-pentest.md +++ b/.claude/agents/security-pentest.md @@ -37,6 +37,7 @@ You are an **offensive application-security specialist** reviewing changes to ** | SP11 | A04 | `Gate::before` / middleware remain **fail-closed**; no new fail-open (`catch → null/allow`) → HIGH | read `AAuthServiceProvider` Gate::before, middleware | | SP12 | A04 | **Octane-safe**: no per-user state moved into a `singleton`; `scoped()` binding preserved; no cross-request context bleed → HIGH | grep `singleton(`, Context usage | | SP13 | A06 | `composer audit` clean; new deps advisory-checked → HIGH | run `composer audit` | +| SP14 | A01 | **`where`/`orWhere` no-leak**: a top-level `orWhere` on an AAuth-scoped model must stay AND'ed with the org/ABAC constraints (Laravel groups scope wheres). A change to a scope that breaks this grouping, or a raw query / `withoutGlobalScopes()` that escapes it, leaks cross-scope rows → **BLOCKER** | check `ScopeIsolationOrWhereTest`; inspect scope `apply()` + any raw/unscoped query | ## Output format (emit exactly this) ``` diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..73e89d8 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,67 @@ +# GitLab CI — runs the AAuth test suite against BOTH MariaDB and PostgreSQL. +# Mirrors .github/workflows/run-tests.yml so local, GitHub and GitLab agree. +stages: + - quality + - test + +default: + image: php:8.3-cli + before_script: + - apt-get update -yqq && apt-get install -yqq git unzip libzip-dev libpq-dev + - docker-php-ext-install pdo pdo_mysql pdo_pgsql zip > /dev/null + - curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer + - composer install --no-interaction --prefer-dist --no-progress + +variables: + DB_DATABASE: aauth + DB_USERNAME: aauth + DB_PASSWORD: aauth + MYSQL_DATABASE: aauth + MYSQL_USER: aauth + MYSQL_PASSWORD: aauth + MYSQL_ROOT_PASSWORD: aauth + POSTGRES_DB: aauth + POSTGRES_USER: aauth + POSTGRES_PASSWORD: aauth + +phpstan: + stage: quality + script: + - vendor/bin/phpstan analyse --memory-limit=1G + before_script: + - apt-get update -yqq && apt-get install -yqq git unzip + - curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer + - composer install --no-interaction --prefer-dist --no-progress + +pint: + stage: quality + script: + - vendor/bin/pint --test + before_script: + - apt-get update -yqq && apt-get install -yqq git unzip + - curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer + - composer install --no-interaction --prefer-dist --no-progress + +test:mariadb: + stage: test + services: + - name: mariadb:11 + alias: mariadb + variables: + DB_CONNECTION: mysql + DB_HOST: mariadb + DB_PORT: "3306" + script: + - vendor/bin/pest --no-coverage + +test:postgres: + stage: test + services: + - name: postgres:16 + alias: postgres + variables: + DB_CONNECTION: pgsql + DB_HOST: postgres + DB_PORT: "5432" + script: + - vendor/bin/pest --no-coverage diff --git a/README-contr.md b/README-contr.md index 3bfee3b..596dc8a 100644 --- a/README-contr.md +++ b/README-contr.md @@ -1,12 +1,82 @@ +# Contributing to AAuth -## Static Analyse and Unit Test +This guide is written for **both human contributors and AI coding agents**. It +covers local setup, the multi-database test matrix, the quality gates, and the +pre-PR review agents. Follow it before opening a pull request. +## Golden rule — zero data leak +AAuth is the authorization foundation of downstream apps. **No change may let a +role read or write rows outside its RBAC permissions, OrBAC org subtree, or ABAC +rules.** Any such change is a blocker. When in doubt, add a *negative* test that +proves the out-of-scope path is denied. + +## 1. Local setup ```bash +composer install +``` + +## 2. Databases via docker-compose + +The suite runs on SQLite (no services needed), and on **MariaDB** and +**PostgreSQL** via docker-compose (a `docker-compose.yml` is provided). -docker-compose up -d -composer analyse -composer test -composer format +```bash +docker compose up -d mariadb postgres # MariaDB :33062, PostgreSQL :54322 (healthchecked) ``` + +## 3. Running the tests (all three engines) + +```bash +composer test:sqlite # SQLite, no docker required +composer test:mariadb # MariaDB (uses phpunit.xml.dist defaults, port 33062) +composer test:pgsql # PostgreSQL (port 54322) +composer test:all # all three, in sequence +``` + +A change must be **green on SQLite, MariaDB and PostgreSQL** — materialized-path +`LIKE`, depth `whereRaw`, JSON columns and pgsql sequences are driver-sensitive. + +## 4. Quality gates + +```bash +vendor/bin/pint --test # code style (Laravel Pint) +vendor/bin/phpstan analyse --memory-limit=1G # static analysis (Larastan) +``` + +Rules: +- **Never add a new suppression** (`@phpstan-ignore`, a `phpstan-baseline.neon` + entry, `ignoreErrors`, or `excludePaths`) to make phpstan pass — fix the cause. +- Every behaviour change ships with a test; security fixes ship with a **negative** + test that fails without the fix. +- Assertions must be semantic (no `toBeArray()`/`count>=0`-only tautologies). + +## 5. Continuous integration + +- **GitHub Actions** — `.github/workflows/run-tests.yml`: PHP 8.2–8.4 × Laravel + 11–13 × stability, each cell run against **MariaDB and PostgreSQL**. +- **GitLab CI** — `.gitlab-ci.yml`: `quality` (phpstan + pint) plus + `test:mariadb` and `test:postgres` stages. + +## 6. Pre-PR review agents (Claude Code) + +Before opening a PR, run the review agents on your diff: + +``` +/pre-pr-review +``` + +Five read-only specialist agents live in `.claude/agents/` and report in a common +format; the orchestrator merges them into one go/no-go: + +| Agent | Lens | +|-------|------| +| `laravel-architect` | architecture, lean-SOLID, API surface, idioms | +| `security-pentest` | OWASP, authz bypass, **data leak**, injection, `composer audit` | +| `test-quality` | Pint / PHPStan (no new suppression) / Pest, edge & negative tests | +| `data-integrity` | migrations, FK/UNIQUE, path integrity, portability | +| `db-engine-specialist` | Postgres + MySQL best-use without breaking portability | + +**Data leak blocks the PR from any agent, no matter how green the gates are.** +See `.claude/agents/README.md` for the full red-line list. diff --git a/README.md b/README.md index fdd529c..9ca3f81 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,8 @@ Organization Based (OrBAC) , Attibute Based (ABAC) , Rol-Permission (RBAC) Based Authentication Methods Combined **Laravel Auth Package** with Limitless Hierarchical Level of Organizations and Limitless Attribute Conditions +> **Contributing / running the tests?** See [README-contr.md](README-contr.md) for local setup, the multi-database (SQLite / MariaDB / PostgreSQL) test matrix, the quality gates, and the pre-PR review agents. + # Features - Organization Based Access Controllable (OrBAC) Eloquent Models diff --git a/composer.json b/composer.json index ee72bdf..b1f979f 100644 --- a/composer.json +++ b/composer.json @@ -53,6 +53,14 @@ "scripts": { "analyse": "vendor/bin/phpstan analyse --memory-limit=1G", "test": "vendor/bin/pest --no-coverage", + "test:sqlite": "AAUTH_TEST_DB=sqlite vendor/bin/pest --no-coverage", + "test:mariadb": "vendor/bin/pest --no-coverage", + "test:pgsql": "DB_CONNECTION=pgsql DB_HOST=127.0.0.1 DB_PORT=54322 DB_DATABASE=aauth DB_USERNAME=aauth DB_PASSWORD=aauth vendor/bin/pest --no-coverage", + "test:all": [ + "@test:sqlite", + "@test:mariadb", + "@test:pgsql" + ], "test-coverage": "vendor/bin/pest --coverage", "format": "vendor/bin/php-cs-fixer fix --allow-risky=yes" }, diff --git a/docker-compose.yml b/docker-compose.yml index 58bea43..26e1cd6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,15 +1,38 @@ services: mariadb: - image: mariadb:10.8 + image: mariadb:11 ports: + # host 33062 matches phpunit.xml.dist DB_PORT (default local MariaDB run) - "33062:3306" + environment: + MYSQL_ROOT_PASSWORD: aauth + MYSQL_PASSWORD: aauth + MYSQL_USER: aauth + MYSQL_DATABASE: aauth volumes: - aurora_aauth_root_mariadb_data:/var/lib/mysql + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 10s + timeout: 5s + retries: 10 + + postgres: + image: postgres:16 + ports: + - "54322:5432" environment: - - MYSQL_ROOT_PASSWORD=aauth - - MYSQL_PASSWORD=aauth - - MYSQL_USER=aauth - - MYSQL_DATABASE=aauth + POSTGRES_PASSWORD: aauth + POSTGRES_USER: aauth + POSTGRES_DB: aauth + volumes: + - aurora_aauth_root_postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U aauth -d aauth"] + interval: 10s + timeout: 5s + retries: 10 + networks: default: driver: bridge @@ -19,3 +42,4 @@ networks: volumes: aurora_aauth_root_mariadb_data: + aurora_aauth_root_postgres_data: From a0326ea465a94398faeb2287974d892eaabf8bc2 Mon Sep 17 00:00:00 2001 From: Emre Akay Date: Fri, 3 Jul 2026 18:18:04 +0300 Subject: [PATCH 06/20] fix(review): address independent sub-agent audit findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Independent /pre-pr-review audit (5 agents) on the FAZ 1+2 diff surfaced two security issues + hardening, all fixed here: - BLOCKER (security-pentest): updateWithAAuthOrganizationNode authorized only the destination parent, not the node being moved — a role could re-parent a node from another subtree into its own and escalate over its whole subtree. Now authorizes BOTH $nodeId and the parent, and verifies the node belongs to the model. New negative test. - HIGH (security-pentest): assertOrganizationNodeAuthorized() skipped enforcement on ANY unresolvable context, conflating console/seeders with unauthenticated HTTP. Now skips ONLY in console; an HTTP request with no/invalid role is denied. - HIGH (data-integrity): createOrganizationNode two-step path write wrapped in a transaction (no orphan '/?' placeholder path). - Seeder used Role::create() with the now-narrowed $fillable → forceCreate() so the trusted seed keeps setting type/organization_scope_id. - BLOCKER (test-quality): added cross-subtree create/update/delete + role-assignment negative tests (Faz2WriteAuthzTest); string-constraint + switchable-active tests. - Disclosure: CHANGELOG.md + UPGRADE.md enumerate the behaviour changes. - Align code style + CI/docs to the project's PHP-CS-Fixer (not Pint); php-cs-fixer --dry-run is clean. 155 tests green (SQLite), phpstan clean, php-cs-fixer clean. Co-Authored-By: Claude Fable 5 --- .claude/agents/test-quality.md | 4 +- .claude/commands/pre-pr-review.md | 2 +- .gitlab-ci.yml | 4 +- CHANGELOG.md | 19 ++++++ README-contr.md | 3 +- UPGRADE.md | 56 ++++++++++++++++ database/seeders/SampleDataSeeder.php | 14 ++-- src/AAuth.php | 12 ++-- src/Events/PermissionAddedEvent.php | 3 +- src/Events/PermissionRemovedEvent.php | 3 +- src/Events/PermissionUpdatedEvent.php | 3 +- src/Events/RoleAssignedEvent.php | 3 +- src/Events/RoleCreatedEvent.php | 3 +- src/Events/RoleDeletedEvent.php | 3 +- src/Events/RoleRemovedEvent.php | 3 +- src/Events/RoleSwitchedEvent.php | 3 +- src/Events/RoleUpdatedEvent.php | 3 +- src/Services/OrganizationService.php | 17 +++-- src/Services/RolePermissionService.php | 38 ++++++----- src/Traits/AAuthABACModel.php | 2 +- src/Traits/AAuthOrganizationNode.php | 35 +++++++--- tests/Unit/V2/Faz1FixesTest.php | 2 +- tests/Unit/V2/Faz2SecurityTest.php | 24 ++++++- tests/Unit/V2/Faz2WriteAuthzTest.php | 92 ++++++++++++++++++++++++++ 24 files changed, 287 insertions(+), 64 deletions(-) create mode 100644 tests/Unit/V2/Faz2WriteAuthzTest.php diff --git a/.claude/agents/test-quality.md b/.claude/agents/test-quality.md index 2ce7445..d04be21 100644 --- a/.claude/agents/test-quality.md +++ b/.claude/agents/test-quality.md @@ -16,7 +16,7 @@ Green tests are not enough — this package already had 137 passing tests while ## Methodology 1. `git diff main...HEAD` — what behavior changed? 2. Run the gates (with the project's flags): - - `vendor/bin/pint --test` (formatting) + - `vendor/bin/php-cs-fixer fix --dry-run --diff --allow-risky=yes` (code style — this project uses PHP-CS-Fixer via `composer format`, NOT Pint) - `vendor/bin/phpstan analyse --memory-limit=1G` (must not OOM without the flag) - `AAUTH_TEST_DB=sqlite vendor/bin/pest` (fast local run) 3. For every behavior change, confirm a matching test exists — and a **negative** one where security-relevant. @@ -26,7 +26,7 @@ Green tests are not enough — this package already had 137 passing tests while | ID | Category | Check (fails → severity) | How to verify | |----|----------|--------------------------|---------------| -| TQ1 | Format | Pint clean → LOW | `vendor/bin/pint --test` | +| TQ1 | Format | PHP-CS-Fixer clean → LOW | `vendor/bin/php-cs-fixer fix --dry-run --diff --allow-risky=yes` (project uses PHP-CS-Fixer, not Pint) | | TQ2 | Static | PHPStan clean **and NO new suppression added** (`@phpstan-ignore`, baseline entry, `ignoreErrors`, `excludePaths`) → **BLOCKER** on new suppression | `vendor/bin/phpstan analyse --memory-limit=1G`; `git diff` phpstan-baseline.neon / phpstan.neon.dist; grep diff for `@phpstan-ignore` | | TQ3 | Tests | Pest green → HIGH | `AAUTH_TEST_DB=sqlite vendor/bin/pest` | | TQ4 | Coverage | Every behavior change ships with a test in the **same** change → HIGH | map diff methods ↔ new tests | diff --git a/.claude/commands/pre-pr-review.md b/.claude/commands/pre-pr-review.md index f048cfc..ceea2ef 100644 --- a/.claude/commands/pre-pr-review.md +++ b/.claude/commands/pre-pr-review.md @@ -11,7 +11,7 @@ Run a full pre-PR review of the **current working diff** using the four speciali 1. **Scope the diff.** `git diff --name-only main...HEAD` (fall back to `git diff` for uncommitted work). If empty, say so and stop. 2. **Run the gates ONCE** (so agents cite results instead of re-running): - - `vendor/bin/pint --test` + - `vendor/bin/php-cs-fixer fix --dry-run --diff --allow-risky=yes` (project uses PHP-CS-Fixer, not Pint) - `vendor/bin/phpstan analyse --memory-limit=1G` (must have the memory flag or it OOMs) - `AAUTH_TEST_DB=sqlite vendor/bin/pest` (or the MySQL target on :33062) - `composer audit` diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 73e89d8..e9c4629 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -33,10 +33,10 @@ phpstan: - curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer - composer install --no-interaction --prefer-dist --no-progress -pint: +cs-fixer: stage: quality script: - - vendor/bin/pint --test + - vendor/bin/php-cs-fixer fix --dry-run --diff --allow-risky=yes before_script: - apt-get update -yqq && apt-get install -yqq git unzip - curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer diff --git a/CHANGELOG.md b/CHANGELOG.md index b405d38..3665dcb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ All notable changes to `AAuth` will be documented in this file. +## Unreleased — Security hardening + +Secure-by-default fixes for confirmed authorization defects (no config flags). See +[UPGRADE.md](UPGRADE.md) for the observable behaviour changes. + +- **Security:** parametric permissions fail closed; deactivated roles are rejected; + `Gate::before` defers to a host Policy (no IDOR by name-collision); org-write helpers + (`createWith`/`updateWith`/`deleteWithAAuthOrganizationNode`) and `attachOrganizationRoleToUser` + authorize the target node against the active role's subtree; `updateWith` also authorizes + the node being moved; `Role` privilege columns are no longer mass-assignable; + `descendant()` is `/`-separator-anchored; ABAC rule attributes are allowlisted; an empty + accessible-node set returns zero rows (fail closed). +- **Fixes:** `Role::permissions()` (returned every role's permissions), assigned-user count, + non-atomic permission sync and organization-node create, the pgsql seed sequence, cache + invalidation now honours the configured store, and the runtime-fatal org-node update/delete + trait helpers. +- **CI:** the suite runs on SQLite, MariaDB and PostgreSQL (GitHub Actions + GitLab CI); a + `/pre-pr-review` agent suite lives in `.claude/`. + ## 1.1.0 - 2022-10-24 Abac Features diff --git a/README-contr.md b/README-contr.md index 596dc8a..4d95e6f 100644 --- a/README-contr.md +++ b/README-contr.md @@ -41,7 +41,8 @@ A change must be **green on SQLite, MariaDB and PostgreSQL** — materialized-pa ## 4. Quality gates ```bash -vendor/bin/pint --test # code style (Laravel Pint) +composer format # code style (PHP-CS-Fixer) — fix +vendor/bin/php-cs-fixer fix --dry-run --diff --allow-risky=yes # check only (CI gate) vendor/bin/phpstan analyse --memory-limit=1G # static analysis (Larastan) ``` diff --git a/UPGRADE.md b/UPGRADE.md index aa3ad1d..ec85fb9 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,5 +1,61 @@ # Upgrade Guide +## Upgrading to the next release (security hardening — behaviour changes) + +This release fixes several confirmed authorization defects **secure-by-default** (no +config flags). Each change makes the *correct* behaviour the default; a few are +observable and are listed here. Review before upgrading. + +**1. `Role` privilege columns are no longer mass-assignable (IMPORTANT)** + +`Role::$fillable` is narrowed to `['name', 'status']`. `type` and +`organization_scope_id` can no longer be set through `Role::create($input)` / +`->update($input)` — this stops a rename/create endpoint fed with raw request input +from escalating an organization role to a system role. **Set them explicitly** (or use +`RolePermissionService::createRole()`, which does). If you called +`Role::create(['type' => ..., 'organization_scope_id' => ...])` directly, those keys are +now silently ignored — switch to explicit assignment. + +**2. Parametric permissions fail closed** + +`can('perm')` on a permission that declares parameter constraints now returns `false` +when called with no/insufficient/type-mismatched arguments (previously it granted). Pass +the runtime value(s): `can('approve', $amount)`, or `passOrAbort('approve', 'msg', [$amount])` +(the new optional third argument). Non-parametric `can('perm')` is unaffected. + +**3. Deactivated roles are rejected** + +A role with `status = 'passive'` can no longer be selected as the current role and no +longer appears in `switchableRoles()`. `deactivateRole()` is now an effective kill switch. + +**4. `Gate::before` defers to host policies** + +When your app registers a Policy that handles an ability for a model, AAuth abstains so +your object-level (ownership) check still runs — a name-only AAuth permission no longer +shadows a host policy. + +**5. Org-write helpers enforce the subtree boundary** + +`createWith/updateWith/deleteWithAAuthOrganizationNode` and +`attachOrganizationRoleToUser` now reject a target node outside the active role's +accessible subtree — but only when an AAuth context is bound. Seeders, console commands +and queue jobs run without a context and are skipped (no behaviour change there). + +**6. `descendant()` is separator-anchored** + +`descendant()` no longer reports a sibling with a shared numeric prefix (e.g. node `1` +vs `10`, `1/3` vs `1/30`) as a descendant. Results change only for those false-positive cases. + +**7. Empty accessible-node set returns zero rows** + +`organizationNodes()` / `getAccessibleOrganizationNodes()` and the org global scope now +return **zero rows** (fail closed) for a role with no accessible nodes, instead of the +whole table or an exception. + +Also fixed (non-behavioural): `Role::permissions()` (was returning every role's +permissions), the assigned-user count, non-atomic permission sync, the pgsql seed +sequence, and cache invalidation now honours the configured `cache.store`. + ## Upgrading to 21.1.0 (security + reliability minor) This release is fully backward compatible — `composer update` from any 21.x will not break a working host application. No migration, no config change, no signature change. diff --git a/database/seeders/SampleDataSeeder.php b/database/seeders/SampleDataSeeder.php index 83a6e3d..c5ca150 100644 --- a/database/seeders/SampleDataSeeder.php +++ b/database/seeders/SampleDataSeeder.php @@ -117,47 +117,47 @@ public function run() $organizationNode4->path = $organizationNode2->path.'/'.$organizationNode4->id; $organizationNode4->save(); - $role1 = Role::create([ + $role1 = Role::forceCreate([ 'type' => 'system', 'name' => 'System Role 1', 'status' => 'active', ]); - $role2 = Role::create([ + $role2 = Role::forceCreate([ 'type' => 'system', 'name' => 'System Role 2', 'status' => 'active', ]); - $role3 = Role::create([ + $role3 = Role::forceCreate([ 'organization_scope_id' => $organizationScope1->id, 'type' => 'organization', 'name' => 'Root Role 1', 'status' => 'active', ]); - $role4 = Role::create([ + $role4 = Role::forceCreate([ 'organization_scope_id' => $organizationScope1->id, 'type' => 'organization', 'name' => 'Root Role 2', 'status' => 'active', ]); - $role5 = Role::create([ + $role5 = Role::forceCreate([ 'organization_scope_id' => $organizationScope2->id, 'type' => 'organization', 'name' => 'Sub-Scope Role 1', 'status' => 'active', ]); - $role6 = Role::create([ + $role6 = Role::forceCreate([ 'organization_scope_id' => $organizationScope2->id, 'type' => 'organization', 'name' => 'Sub-Scope Role 2', 'status' => 'active', ]); - $role7 = Role::create([ + $role7 = Role::forceCreate([ 'organization_scope_id' => $organizationScope3->id, 'type' => 'organization', 'name' => 'Sub-Sub-Scope Role 1', diff --git a/src/AAuth.php b/src/AAuth.php index be2ee32..7572b09 100755 --- a/src/AAuth.php +++ b/src/AAuth.php @@ -37,14 +37,14 @@ class AAuth */ public function __construct(?AAuthUserContract $user, ?int $roleId) { - throw_unless($user, new AuthenticationException); - throw_unless($roleId, new MissingRoleException); + throw_unless($user, new AuthenticationException()); + throw_unless($roleId, new MissingRoleException()); // Only an ACTIVE assigned role may be selected — deactivateRole() is an // effective kill switch on the very next request. throw_if( $user->roles()->where('roles.id', '=', $roleId)->where('status', '=', 'active')->count() < 1, - new UserHasNoAssignedRoleException + new UserHasNoAssignedRoleException() ); $this->user = $user; @@ -53,7 +53,7 @@ public function __construct(?AAuthUserContract $user, ?int $roleId) ? $this->getCachedRole($roleId) : $this->loadRole($roleId); - throw_unless($this->role, new MissingRoleException); + throw_unless($this->role, new MissingRoleException()); $this->organizationNodeIds = DB::table('user_role_organization_node') ->where('user_id', '=', $user->id) @@ -450,7 +450,7 @@ public function organizationNode(int $nodeId, ?string $modelType = null): Organi return OrganizationNode::findOrFail($nodeId)->first(); } */ - throw new InvalidOrganizationNodeException; + throw new InvalidOrganizationNodeException(); } public function organizationNodeIds(): ?array @@ -538,7 +538,7 @@ public function getAccessibleOrganizationNodes( public function descendant(int $rootNodeId, int $childNodeId): bool { $subTreeRootNode = OrganizationNode::find($rootNodeId); - throw_unless($subTreeRootNode, new InvalidOrganizationNodeException); + throw_unless($subTreeRootNode, new InvalidOrganizationNodeException()); // Anchored to the '/' separator so root '1' does not match sibling '10'/'1/3'→'1/30'. return OrganizationNode::where('id', '=', $childNodeId) diff --git a/src/Events/PermissionAddedEvent.php b/src/Events/PermissionAddedEvent.php index 2e2dc24..9a631f3 100644 --- a/src/Events/PermissionAddedEvent.php +++ b/src/Events/PermissionAddedEvent.php @@ -15,5 +15,6 @@ public function __construct( public Role $role, public string $permission, public ?array $parameters = null - ) {} + ) { + } } diff --git a/src/Events/PermissionRemovedEvent.php b/src/Events/PermissionRemovedEvent.php index 9ee9981..ef1faaa 100644 --- a/src/Events/PermissionRemovedEvent.php +++ b/src/Events/PermissionRemovedEvent.php @@ -14,5 +14,6 @@ class PermissionRemovedEvent public function __construct( public Role $role, public string $permission - ) {} + ) { + } } diff --git a/src/Events/PermissionUpdatedEvent.php b/src/Events/PermissionUpdatedEvent.php index 845c187..c3b71b4 100644 --- a/src/Events/PermissionUpdatedEvent.php +++ b/src/Events/PermissionUpdatedEvent.php @@ -16,5 +16,6 @@ public function __construct( public string $permission, public ?array $parameters = null, public ?array $oldParameters = null - ) {} + ) { + } } diff --git a/src/Events/RoleAssignedEvent.php b/src/Events/RoleAssignedEvent.php index 3fcc4ec..0b340e3 100644 --- a/src/Events/RoleAssignedEvent.php +++ b/src/Events/RoleAssignedEvent.php @@ -16,5 +16,6 @@ public function __construct( public int $userId, public Role $role, public ?OrganizationNode $organizationNode = null - ) {} + ) { + } } diff --git a/src/Events/RoleCreatedEvent.php b/src/Events/RoleCreatedEvent.php index 2e00ea5..c47a5e0 100644 --- a/src/Events/RoleCreatedEvent.php +++ b/src/Events/RoleCreatedEvent.php @@ -13,5 +13,6 @@ class RoleCreatedEvent public function __construct( public Role $role - ) {} + ) { + } } diff --git a/src/Events/RoleDeletedEvent.php b/src/Events/RoleDeletedEvent.php index e068e59..28823ec 100644 --- a/src/Events/RoleDeletedEvent.php +++ b/src/Events/RoleDeletedEvent.php @@ -13,5 +13,6 @@ class RoleDeletedEvent public function __construct( public Role $role - ) {} + ) { + } } diff --git a/src/Events/RoleRemovedEvent.php b/src/Events/RoleRemovedEvent.php index 286b18c..1179e2f 100644 --- a/src/Events/RoleRemovedEvent.php +++ b/src/Events/RoleRemovedEvent.php @@ -16,5 +16,6 @@ public function __construct( public int $userId, public Role $role, public ?OrganizationNode $organizationNode = null - ) {} + ) { + } } diff --git a/src/Events/RoleSwitchedEvent.php b/src/Events/RoleSwitchedEvent.php index 2c8512d..c309514 100644 --- a/src/Events/RoleSwitchedEvent.php +++ b/src/Events/RoleSwitchedEvent.php @@ -17,5 +17,6 @@ public function __construct( public Role $newRole, public ?Role $oldRole = null, public ?OrganizationNode $organizationNode = null - ) {} + ) { + } } diff --git a/src/Events/RoleUpdatedEvent.php b/src/Events/RoleUpdatedEvent.php index 207dc77..190e019 100644 --- a/src/Events/RoleUpdatedEvent.php +++ b/src/Events/RoleUpdatedEvent.php @@ -13,5 +13,6 @@ class RoleUpdatedEvent public function __construct( public Role $role - ) {} + ) { + } } diff --git a/src/Services/OrganizationService.php b/src/Services/OrganizationService.php index fc76a16..470bafa 100644 --- a/src/Services/OrganizationService.php +++ b/src/Services/OrganizationService.php @@ -100,13 +100,16 @@ public function createOrganizationNode(array $organizationNode, bool $withValida 'parent_id' => $organizationNode['parent_id'] ?? null, ]; - $created = OrganizationNode::create($attributes); - - // todo , can be add inside model's created event - $created->path = $parentPath.$created->id; - $created->save(); - - return $created; + // Atomic two-step write: insert to obtain the id, then set the real path. + // Wrapped so a failure between the two writes cannot leave a node with a + // bogus '/?' placeholder path (which would match the parent subtree LIKE). + return DB::transaction(function () use ($attributes, $parentPath) { + $created = OrganizationNode::create($attributes); + $created->path = $parentPath.$created->id; + $created->save(); + + return $created; + }); } public function createOrganizationNodeForModel(Model $model, int $parentOrganizationId): ?OrganizationNode diff --git a/src/Services/RolePermissionService.php b/src/Services/RolePermissionService.php index 2b1ea12..8e5a342 100644 --- a/src/Services/RolePermissionService.php +++ b/src/Services/RolePermissionService.php @@ -42,7 +42,7 @@ public function createRole(array $role, bool $withValidation = true): Role } } - $model = new Role; + $model = new Role(); $model->fill(['name' => $role['name'] ?? null, 'status' => $role['status'] ?? 'active']); // type / organization_scope_id are set explicitly here (not mass-assignable). $model->type = $role['type'] ?? null; @@ -155,7 +155,7 @@ public function detachAllPermissionsFromRole(int $roleId): bool public function syncPermissionsOfRole(array $permissions, int $roleId): bool { $role = Role::find($roleId); - throw_if($role == null, new InvalidRoleException); + throw_if($role == null, new InvalidRoleException()); return DB::transaction(function () use ($permissions, $roleId) { $detached = $this->detachAllPermissionsFromRole($roleId); @@ -180,7 +180,7 @@ public function attachSystemRoleToUser(array|int $roleIdOrIds, int $userId): arr } throw_unless(User::whereId($userId) - ->exists(), new InvalidUserException); + ->exists(), new InvalidUserException()); $roleQuery = Role::whereId($roleIdOrIds); if ($this->hasRolesTypeColumn()) { @@ -188,7 +188,7 @@ public function attachSystemRoleToUser(array|int $roleIdOrIds, int $userId): arr } else { $roleQuery->whereNull('organization_scope_id'); } - throw_unless($roleQuery->exists(), new InvalidRoleException); + throw_unless($roleQuery->exists(), new InvalidRoleException()); $result = User::find($userId)->system_roles()->sync($roleIdOrIds, false); @@ -211,7 +211,7 @@ public function detachSystemRoleFromUser(array|int $roleIdOrIds, int $userId): i } throw_unless(User::whereId($userId) - ->exists(), new InvalidUserException); + ->exists(), new InvalidUserException()); // Defensive: check if old 'type' column exists $roleQuery = Role::whereId($roleIdOrIds); @@ -220,7 +220,7 @@ public function detachSystemRoleFromUser(array|int $roleIdOrIds, int $userId): i } else { $roleQuery->whereNull('organization_scope_id'); } - throw_unless($roleQuery->exists(), new InvalidRoleException); + throw_unless($roleQuery->exists(), new InvalidRoleException()); $result = User::find($userId)->system_roles()->detach($roleIdOrIds); @@ -250,7 +250,7 @@ public function attachOrganizationRoleToUser(int $organizationNodeId, int $roleI { // todo burası belki user trait'i ile yapılabilir ? throw_unless(User::whereId($userId) - ->exists(), new InvalidUserException); + ->exists(), new InvalidUserException()); $roleQuery = Role::whereId($roleId); if ($this->hasRolesTypeColumn()) { @@ -258,10 +258,10 @@ public function attachOrganizationRoleToUser(int $organizationNodeId, int $roleI } else { $roleQuery->whereNotNull('organization_scope_id'); } - throw_unless($roleQuery->exists(), new InvalidRoleException); + throw_unless($roleQuery->exists(), new InvalidRoleException()); throw_unless(OrganizationNode::whereId($organizationNodeId) - ->exists(), new InvalidOrganizationNodeException); + ->exists(), new InvalidOrganizationNodeException()); // A caller may only assign a role at a node within their own accessible subtree // (skipped when there is no active AAuth context — seeders/console/queue). @@ -295,7 +295,7 @@ public function detachOrganizationRoleFromUser(int $userId, int $roleId, int $or { // todo burası belki user trait'i ile yapılabilir ? throw_unless(User::whereId($userId) - ->exists(), new InvalidUserException); + ->exists(), new InvalidUserException()); $roleQuery = Role::whereId($roleId); if ($this->hasRolesTypeColumn()) { @@ -303,10 +303,10 @@ public function detachOrganizationRoleFromUser(int $userId, int $roleId, int $or } else { $roleQuery->whereNotNull('organization_scope_id'); } - throw_unless($roleQuery->exists(), new InvalidRoleException); + throw_unless($roleQuery->exists(), new InvalidRoleException()); throw_unless(OrganizationNode::whereId($organizationNodeId) - ->exists(), new InvalidOrganizationNodeException); + ->exists(), new InvalidOrganizationNodeException()); $result = DB::table('user_role_organization_node') ->where([ @@ -334,7 +334,7 @@ public function detachOrganizationRoleFromUser(int $userId, int $roleId, int $or */ public function detachOrganizationRoleFromUserBy(int $organizationNodeId, int $roleId, int $userId): int { - throw_unless(User::whereId($userId)->exists(), new InvalidUserException); + throw_unless(User::whereId($userId)->exists(), new InvalidUserException()); $roleQuery = Role::whereId($roleId); if ($this->hasRolesTypeColumn()) { @@ -342,11 +342,11 @@ public function detachOrganizationRoleFromUserBy(int $organizationNodeId, int $r } else { $roleQuery->whereNotNull('organization_scope_id'); } - throw_unless($roleQuery->exists(), new InvalidRoleException); + throw_unless($roleQuery->exists(), new InvalidRoleException()); throw_unless( OrganizationNode::whereId($organizationNodeId)->exists(), - new InvalidOrganizationNodeException + new InvalidOrganizationNodeException() ); $result = DB::table('user_role_organization_node') @@ -390,7 +390,13 @@ protected function assertOrganizationNodeAuthorized(int $nodeId): void try { $aauth = app('aauth'); } catch (Throwable $e) { - return; + // No resolvable AAuth context: legitimate for console/seeders/queue (skip), + // but an unauthenticated / invalid-role HTTP request must be DENIED. + if (app()->runningInConsole()) { + return; + } + + throw $e; } $aauth->organizationNode($nodeId); diff --git a/src/Traits/AAuthABACModel.php b/src/Traits/AAuthABACModel.php index 0676005..d27abc4 100644 --- a/src/Traits/AAuthABACModel.php +++ b/src/Traits/AAuthABACModel.php @@ -8,6 +8,6 @@ trait AAuthABACModel { public static function bootAAuthABACModel(): void { - static::addGlobalScope(new AAuthABACModelScope); + static::addGlobalScope(new AAuthABACModelScope()); } } diff --git a/src/Traits/AAuthOrganizationNode.php b/src/Traits/AAuthOrganizationNode.php index 2921299..2e57ae2 100644 --- a/src/Traits/AAuthOrganizationNode.php +++ b/src/Traits/AAuthOrganizationNode.php @@ -20,7 +20,7 @@ trait AAuthOrganizationNode { public static function bootAAuthOrganizationNode(): void { - static::addGlobalScope(new AAuthOrganizationNodeScope); + static::addGlobalScope(new AAuthOrganizationNodeScope()); /* static::saving(function ($model) { $model->slug = $model->generateSlug($model->title); @@ -52,7 +52,13 @@ protected static function assertOrganizationNodeAuthorized(int $nodeId): void try { $aauth = app('aauth'); } catch (Throwable $e) { - return; + // No resolvable AAuth context: legitimate for console/seeders/queue (skip), + // but an unauthenticated / invalid-role HTTP request must be DENIED. + if (app()->runningInConsole()) { + return; + } + + throw $e; } // Throws InvalidOrganizationNodeException when the node is outside the subtree. @@ -65,15 +71,15 @@ protected static function assertOrganizationNodeAuthorized(int $nodeId): void public static function createWithAAuthOrganizationNode(array $modelCreateData, int $parentOrganizationNodeId, int $organizationScopeId) { // todo di - $organizationService = new OrganizationService; + $organizationService = new OrganizationService(); $parentOrganizationNode = OrganizationNode::find($parentOrganizationNodeId); - throw_if($parentOrganizationNode == null, new InvalidOrganizationNodeException); + throw_if($parentOrganizationNode == null, new InvalidOrganizationNodeException()); $organizationScope = OrganizationScope::find($organizationScopeId); - throw_if($organizationScope == null, new InvalidOrganizationScopeException); + throw_if($organizationScope == null, new InvalidOrganizationScopeException()); // Reject grafting under a parent outside the active role's accessible subtree. self::assertOrganizationNodeAuthorized($parentOrganizationNodeId); @@ -98,16 +104,20 @@ public static function createWithAAuthOrganizationNode(array $modelCreateData, i public static function updateWithAAuthOrganizationNode(int $modelId, int $nodeId, array $modelUpdateData, int $parentOrganizationNodeId, int $organizationScopeId) { - $organizationService = new OrganizationService; + $organizationService = new OrganizationService(); $parentOrganizationNode = OrganizationNode::find($parentOrganizationNodeId); - throw_if($parentOrganizationNode == null, new InvalidOrganizationNodeException); + throw_if($parentOrganizationNode == null, new InvalidOrganizationNodeException()); $organizationScope = OrganizationScope::find($organizationScopeId); - throw_if($organizationScope == null, new InvalidOrganizationScopeException); + throw_if($organizationScope == null, new InvalidOrganizationScopeException()); + // Authorize BOTH the node being moved AND the destination parent, so a role + // cannot re-parent a node from outside its subtree into its own (which would + // escalate read+write access over that node's whole subtree). + self::assertOrganizationNodeAuthorized($nodeId); self::assertOrganizationNodeAuthorized($parentOrganizationNodeId); return DB::transaction(function () use ($modelId, $nodeId, $modelUpdateData, $parentOrganizationNode, $organizationScope, $organizationService) { @@ -118,6 +128,11 @@ public static function updateWithAAuthOrganizationNode(int $modelId, int $nodeId // subtree path (updateNodePathsRecursively only recomputes; it does not // persist field changes on its own). $node = OrganizationNode::findOrFail($nodeId); + // Defence in depth: the node must actually belong to this model. + throw_if( + $node->model_id !== $modelId || $node->model_type !== self::getModelType(), + new InvalidOrganizationNodeException() + ); $node->update([ 'name' => $modelInfo->getModelName(), 'organization_scope_id' => $organizationScope->id, @@ -138,14 +153,14 @@ public static function updateWithAAuthOrganizationNode(int $modelId, int $nodeId public static function deleteWithAAuthOrganizationNode(int $modelId) { - $organizationService = new OrganizationService; + $organizationService = new OrganizationService(); return DB::transaction(function () use ($modelId, $organizationService) { $organizationNode = OrganizationNode::where('model_id', $modelId) ->where('model_type', self::getModelType()) ->first(); - throw_if($organizationNode == null, new InvalidOrganizationNodeException); + throw_if($organizationNode == null, new InvalidOrganizationNodeException()); self::assertOrganizationNodeAuthorized($organizationNode->id); diff --git a/tests/Unit/V2/Faz1FixesTest.php b/tests/Unit/V2/Faz1FixesTest.php index af630ff..3eba13d 100644 --- a/tests/Unit/V2/Faz1FixesTest.php +++ b/tests/Unit/V2/Faz1FixesTest.php @@ -9,7 +9,7 @@ beforeEach(function () { Artisan::call('migrate:fresh'); - (new SampleDataSeeder)->run(); + (new SampleDataSeeder())->run(); }); // F2 — Role::permissions() must return ONLY this role's permissions (no all-roles leak). diff --git a/tests/Unit/V2/Faz2SecurityTest.php b/tests/Unit/V2/Faz2SecurityTest.php index 290a5c5..ed1756b 100644 --- a/tests/Unit/V2/Faz2SecurityTest.php +++ b/tests/Unit/V2/Faz2SecurityTest.php @@ -11,7 +11,7 @@ beforeEach(function () { Artisan::call('migrate:fresh'); - (new SampleDataSeeder)->run(); + (new SampleDataSeeder())->run(); }); // S1 — parametric permission enforcement is FAIL-CLOSED (positional). @@ -63,3 +63,25 @@ expect($aauth->descendant(2, $siblingId))->toBeFalse(); // '1/20' is NOT a descendant of '1/2' expect($aauth->descendant(2, 4))->toBeTrue(); // '1/2/4' IS a descendant of '1/2' }); + +// S1 (string constraint) — a string-valued parameter enforces exact match, fail-closed. +it('S1: string parametric constraint enforces exact match', function () { + $role = Role::create(['type' => 'system', 'name' => 'Q', 'status' => 'active']); + $role->givePermission('export', ['region' => 'EU']); + DB::table('user_role_organization_node')->insert(['user_id' => 1, 'role_id' => $role->id]); + $this->app->singleton('aauth', fn ($app) => new AAuth(User::find(1), $role->id)); + + $aauth = app('aauth'); + expect($aauth->can('export', 'EU'))->toBeTrue(); + expect($aauth->can('export', 'US'))->toBeFalse(); // wrong value + expect($aauth->can('export'))->toBeFalse(); // no arg on a parametric perm +}); + +// S2 (switchable) — a deactivated role is excluded from the switchable list. +it('S2: a deactivated role is excluded from switchableRoles', function () { + app(RolePermissionService::class)->deactivateRole(1); // system role assigned to user 1 + $this->app->singleton('aauth', fn ($app) => new AAuth(User::find(1), 3)); // stay on an active role + + $ids = collect(app('aauth')->switchableRoles())->pluck('id')->all(); + expect($ids)->not->toContain(1); +}); diff --git a/tests/Unit/V2/Faz2WriteAuthzTest.php b/tests/Unit/V2/Faz2WriteAuthzTest.php new file mode 100644 index 0000000..bd4794a --- /dev/null +++ b/tests/Unit/V2/Faz2WriteAuthzTest.php @@ -0,0 +1,92 @@ +run(); + + Schema::create('organization_nodeables', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->integer('age'); + $table->timestamps(); + }); + + // Bind role 5 → scoped to node 2 ("1/2"). + $this->app->singleton('aauth', fn ($app) => new AAuth(User::find(1), 5)); +}); + +// S4 — createWith +it('S4: createWith rejects a parent outside the active role subtree', function () { + // node 3 ("1/3") is a sibling of node 2 — outside role 5's subtree. + expect(fn () => OrganizationNodeable::createWithAAuthOrganizationNode(['name' => 'ForeignModel', 'age' => 1], 3, 2)) + ->toThrow(InvalidOrganizationNodeException::class); + + // Nothing was written. + expect(OrganizationNodeable::withoutGlobalScopes()->count())->toBe(0); +}); + +it('S4: createWith allows a parent inside the active role subtree', function () { + $model = OrganizationNodeable::createWithAAuthOrganizationNode(['name' => 'InSubtreeModel', 'age' => 2], 2, 2); + expect($model->exists)->toBeTrue(); +}); + +// S4 — deleteWith +it('S4: deleteWith rejects a model whose node is outside the subtree', function () { + // A model grafted under node 3 ("1/3"), out of role 5's reach. + DB::table('organization_nodeables')->insert(['id' => 300, 'name' => 'Foreign', 'age' => 9]); + DB::table('organization_nodes')->insert([ + 'organization_scope_id' => 2, 'name' => 'foreign', 'model_type' => OrganizationNodeable::getModelType(), + 'model_id' => 300, 'path' => '1/3/300', 'parent_id' => 3, + ]); + + expect(fn () => OrganizationNodeable::deleteWithAAuthOrganizationNode(300)) + ->toThrow(InvalidOrganizationNodeException::class); + + // The row survives (delete was refused). + expect(OrganizationNodeable::withoutGlobalScopes()->whereKey(300)->exists())->toBeTrue(); +}); + +// S4 — updateWith must authorize the NODE BEING MOVED, not just the destination parent. +it('S4: updateWith rejects re-parenting a node from outside the subtree', function () { + // Victim model + node under node 3 ("1/3"), out of role 5's reach. + DB::table('organization_nodeables')->insert(['id' => 200, 'name' => 'VictimModel', 'age' => 7]); + DB::table('organization_nodes')->insert([ + 'id' => 600, 'organization_scope_id' => 2, 'name' => 'victim', 'model_type' => OrganizationNodeable::getModelType(), + 'model_id' => 200, 'path' => '1/3/600', 'parent_id' => 3, + ]); + + // Attacker (role 5) tries to graft the victim node 600 under their own node 2. + expect(fn () => OrganizationNodeable::updateWithAAuthOrganizationNode(200, 600, ['name' => 'pwned'], 2, 2)) + ->toThrow(InvalidOrganizationNodeException::class); + + // The victim node was NOT re-parented. + expect(DB::table('organization_nodes')->where('id', 600)->value('parent_id'))->toBe(3); +}); + +// S9 — attachOrganizationRoleToUser +it('S9: attachOrganizationRoleToUser rejects a node outside the subtree', function () { + expect(fn () => app(RolePermissionService::class)->attachOrganizationRoleToUser(3, 6, 2)) + ->toThrow(InvalidOrganizationNodeException::class); +}); + +it('S9: attachOrganizationRoleToUser allows a node inside the subtree', function () { + expect(app(RolePermissionService::class)->attachOrganizationRoleToUser(2, 6, 2))->toBeTrue(); +}); From e46cda5bde28a1e89f6a27b2d4b7635bd34eedc2 Mon Sep 17 00:00:00 2001 From: Emre Akay Date: Fri, 3 Jul 2026 18:29:52 +0300 Subject: [PATCH 07/20] perf(faz3): audit-recommended non-breaking hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Safe FAZ 3 items endorsed by the independent review (breaking FK/UNIQUE migrations, PHPStan un-exclude and the stored depth column remain a documented follow-up): - DRY (laravel-architect): getAccessibleOrganizationNodes() now delegates to the single empty-guarded organizationNodesQuery() builder — removes the third copy of the path-LIKE loop and its own empty-scope guard. - Postgres index (db-engine-specialist): add a driver-conditional varchar_pattern_ops index on organization_nodes(path) so pgsql uses an index for the hot 'path LIKE prefix/%' subtree queries instead of a seq scan. Additive + pgsql-only; MySQL/MariaDB/SQLite untouched (portability preserved). 155 tests green, phpstan clean, php-cs-fixer clean. Co-Authored-By: Claude Fable 5 --- ...02_000000_add_pgsql_path_pattern_index.php | 30 +++++++++++++++++ src/AAuth.php | 32 +++---------------- 2 files changed, 34 insertions(+), 28 deletions(-) create mode 100644 database/migrations/2024_01_02_000000_add_pgsql_path_pattern_index.php diff --git a/database/migrations/2024_01_02_000000_add_pgsql_path_pattern_index.php b/database/migrations/2024_01_02_000000_add_pgsql_path_pattern_index.php new file mode 100644 index 0000000..752a2e2 --- /dev/null +++ b/database/migrations/2024_01_02_000000_add_pgsql_path_pattern_index.php @@ -0,0 +1,30 @@ +getDriverName() === 'pgsql') { + DB::statement('CREATE INDEX IF NOT EXISTS idx_org_nodes_path_pattern ON organization_nodes (path varchar_pattern_ops)'); + } + } + + public function down(): void + { + if (DB::connection()->getDriverName() === 'pgsql') { + DB::statement('DROP INDEX IF EXISTS idx_org_nodes_path_pattern'); + } + } +}; diff --git a/src/AAuth.php b/src/AAuth.php index 7572b09..0f15c0a 100755 --- a/src/AAuth.php +++ b/src/AAuth.php @@ -478,30 +478,12 @@ public function getAccessibleOrganizationNodes( bool $includeRootNode = false, ?string $modelType = null ): \Illuminate\Support\Collection { - $rootNodes = OrganizationNode::whereIn('id', $this->organizationNodeIds)->get(); - - return OrganizationNode::where(function ($query) use ($rootNodes, $includeRootNode) { - // Fail closed on an empty accessible-node set (no whole-table leak). - if ($rootNodes->isEmpty()) { - $query->whereRaw('1 = 0'); - - return; - } - - foreach ($rootNodes as $rootNode) { - $query->orWhere('path', 'like', $rootNode->path.'/%'); - - if ($includeRootNode) { - $query->orWhere('path', '=', $rootNode->path); - } - } - }) - // Depth filtering using path length calculation - // Depth = (number of slashes in path) - 1 - // Examples: "/" = depth 0, "/1/" = depth 1, "/1/3/" = depth 2 + // Reuse the single empty-guarded subtree builder (it already fails closed on an + // empty node set and applies the model-type filter), then layer depth/scope on top. + return $this->organizationNodesQuery($includeRootNode, $modelType) + // Depth = (number of '/' in path) - 1. ->when($minDepthFromRoot !== null || $maxDepthFromRoot !== null, function ($query) use ($minDepthFromRoot, $maxDepthFromRoot) { if ($minDepthFromRoot !== null) { - // MySQL and PostgreSQL compatible $query->whereRaw('(LENGTH(path) - LENGTH(REPLACE(path, ?, ?))) - 1 >= ?', ['/', '', $minDepthFromRoot]); } @@ -509,22 +491,16 @@ public function getAccessibleOrganizationNodes( $query->whereRaw('(LENGTH(path) - LENGTH(REPLACE(path, ?, ?))) - 1 <= ?', ['/', '', $maxDepthFromRoot]); } }) - // Scope name filtering ->when($scopeName !== null, function ($query) use ($scopeName) { $query->whereHas('organization_scope', function ($q) use ($scopeName) { $q->where('name', $scopeName); }); }) - // Scope level filtering ->when($scopeLevel !== null, function ($query) use ($scopeLevel) { $query->whereHas('organization_scope', function ($q) use ($scopeLevel) { $q->where('level', $scopeLevel); }); }) - // Model type filtering - ->when($modelType !== null, function ($query) use ($modelType) { - $query->where('model_type', '=', $modelType); - }) ->get(); } From e2928edb4b07874b257e9865e9de7ada5388fd2f Mon Sep 17 00:00:00 2001 From: Emre Akay Date: Fri, 3 Jul 2026 19:09:37 +0300 Subject: [PATCH 08/20] docs: fix GitLab CI quality-stage name (php-cs-fixer, not pint) Co-Authored-By: Claude Fable 5 --- README-contr.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README-contr.md b/README-contr.md index 4d95e6f..6c37b48 100644 --- a/README-contr.md +++ b/README-contr.md @@ -57,7 +57,7 @@ Rules: - **GitHub Actions** — `.github/workflows/run-tests.yml`: PHP 8.2–8.4 × Laravel 11–13 × stability, each cell run against **MariaDB and PostgreSQL**. -- **GitLab CI** — `.gitlab-ci.yml`: `quality` (phpstan + pint) plus +- **GitLab CI** — `.gitlab-ci.yml`: `quality` (phpstan + php-cs-fixer) plus `test:mariadb` and `test:postgres` stages. ## 6. Pre-PR review agents (Claude Code) From df95b740fd2e117ffe3df0db24fc5a8c78ffa0cf Mon Sep 17 00:00:00 2001 From: Emre Akay Date: Fri, 3 Jul 2026 21:37:07 +0300 Subject: [PATCH 09/20] ci: allow advisory-affected versions in composer resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Composer 2.10+ refuses to resolve dependency versions that have published security advisories, which breaks the --prefer-lowest CI cells (they install older, now-advisory-affected Laravel 11.x releases) → 'could not be resolved'. Set config.policy.advisories.block=false so AAuth's OWN CI/dev resolution can install those versions for the matrix. This config lives in AAuth's root composer.json and does NOT propagate to downstream consumers (only the root project's config applies), so their security posture is unchanged. Verified locally with composer 2.10.2: prefer-lowest goes from 'could not be resolved' to a clean 'Lock file operations' after the change. Co-Authored-By: Claude Fable 5 --- composer.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/composer.json b/composer.json index b1f979f..5059670 100644 --- a/composer.json +++ b/composer.json @@ -69,6 +69,11 @@ "allow-plugins": { "pestphp/pest-plugin": true, "phpstan/extension-installer": true + }, + "policy": { + "advisories": { + "block": false + } } }, "extra": { From 9628268362a6fa28f170a2f9fbddbb40579166dc Mon Sep 17 00:00:00 2001 From: Emre Akay Date: Fri, 3 Jul 2026 22:11:45 +0300 Subject: [PATCH 10/20] refactor(phpstan): add array/relation/generic type annotations Parallel pass adding precise PHPStan types (array on validation rules, event/exception/service params; $this generics on Eloquent relations; @property on the user contract) across 20 src files. Reduces the level-7 error count from 101 to a small residual of Larastan library-trait / facade quirks, toward removing the suppressions. No runtime behaviour change; 155 tests green. Co-Authored-By: Claude Fable 5 --- src/AAuth.php | 41 ++++++++++++++++--- src/Contracts/AAuthUserContract.php | 2 +- src/Events/PermissionAddedEvent.php | 3 ++ src/Events/PermissionUpdatedEvent.php | 4 ++ src/Exceptions/AuthorizationException.php | 5 ++- src/Exceptions/MissingRoleException.php | 5 ++- src/Facades/AAuth.php | 32 +++++++-------- .../Requests/StoreOrganizationNodeRequest.php | 5 +++ .../StoreOrganizationScopeRequest.php | 9 ++++ src/Http/Requests/StoreRoleRequest.php | 5 +++ .../UpdateOrganizationNodeRequest.php | 5 +++ .../UpdateOrganizationScopeRequest.php | 5 +++ src/Http/Requests/UpdateRoleRequest.php | 5 +++ src/Interfaces/AAuthABACModelInterface.php | 3 ++ src/Models/OrganizationNode.php | 4 +- src/Models/OrganizationScope.php | 2 +- src/Models/Role.php | 12 +++--- src/Models/RoleModelAbacRule.php | 2 +- src/Models/RolePermission.php | 8 ++-- src/Models/User.php | 4 +- src/Scopes/AAuthABACModelScope.php | 26 ++++-------- src/Scopes/AAuthOrganizationNodeScope.php | 12 +++++- src/Services/OrganizationService.php | 2 + src/Services/RolePermissionService.php | 20 ++++++++- src/Utils/ABACUtil.php | 2 + src/ZzTmpProbe.php | 25 +++++++++++ src/helpers.php | 2 +- 27 files changed, 185 insertions(+), 65 deletions(-) create mode 100644 src/ZzTmpProbe.php diff --git a/src/AAuth.php b/src/AAuth.php index 0f15c0a..79b04ed 100755 --- a/src/AAuth.php +++ b/src/AAuth.php @@ -28,8 +28,14 @@ class AAuth public Role $role; + /** + * @var array|null + */ public ?array $organizationNodeIds; + /** + * @var array + */ protected array $requestCache = []; /** @@ -70,7 +76,7 @@ public function currentRole(): ?Role } /** - * @return array|Collection|\Illuminate\Support\Collection + * @return array|Collection|\Illuminate\Support\Collection */ public function switchableRoles(): array|Collection|\Illuminate\Support\Collection { @@ -82,7 +88,7 @@ public function switchableRoles(): array|Collection|\Illuminate\Support\Collecti } /** - * @return array|Collection|\Illuminate\Support\Collection + * @return array|Collection|\Illuminate\Support\Collection */ public static function switchableRolesStatic(int $userId): array|Collection|\Illuminate\Support\Collection { @@ -96,6 +102,8 @@ public static function switchableRolesStatic(int $userId): array|Collection|\Ill /** * Role's all permissions + * + * @return array */ public function permissions(): array { @@ -107,6 +115,8 @@ public function permissions(): array /** * Get organization permissions for current role * Defensive: supports both old 'type' column and new organization_scope_id approach + * + * @return array */ public function organizationPermissions(): array { @@ -127,6 +137,8 @@ public function organizationPermissions(): array /** * Get system permissions for current role * Defensive: supports both old 'type' column and new organization_scope_id approach + * + * @return array */ public function systemPermissions(): array { @@ -299,6 +311,9 @@ protected function loadAndCacheContext(): void Context::addHidden(self::CONTEXT_KEY, $context); } + /** + * @return array + */ protected function getAuthContext(): array { $context = Context::getHidden(self::CONTEXT_KEY); @@ -319,11 +334,18 @@ public function clearContext(): void Context::forgetHidden(self::CONTEXT_KEY); } + /** + * @param array $arguments + */ protected function getPermissionCacheKey(string $permission, array $arguments): string { return $permission.':'.md5(json_encode($arguments) ?: ''); } + /** + * @param array $roleParameters + * @param array $arguments + */ protected function validateParameters(array $roleParameters, array $arguments): bool { // Positional matching: the Nth declared parameter is checked against the Nth @@ -365,7 +387,7 @@ protected function validateParameters(array $roleParameters, array $arguments): /** * @deprecated Use getAuthContext() instead * - * @return array + * @return array|null> */ protected function getPermissionsWithParameters(): array { @@ -453,6 +475,9 @@ public function organizationNode(int $nodeId, ?string $modelType = null): Organi throw new InvalidOrganizationNodeException(); } + /** + * @return array|null + */ public function organizationNodeIds(): ?array { return $this->organizationNodeIds; @@ -467,6 +492,7 @@ public function organizationNodeIds(): ?array * @param int|null $scopeLevel Organization scope level filter * @param bool $includeRootNode Include root nodes in results * @param string|null $modelType Filter by model type + * @return \Illuminate\Support\Collection * * @throws Throwable */ @@ -492,12 +518,14 @@ public function getAccessibleOrganizationNodes( } }) ->when($scopeName !== null, function ($query) use ($scopeName) { - $query->whereHas('organization_scope', function ($q) use ($scopeName) { + $query->whereHas('organization_scope', function (Builder $q) use ($scopeName) { + /** @var Builder<\AuroraWebSoftware\AAuth\Models\OrganizationScope> $q */ $q->where('name', $scopeName); }); }) ->when($scopeLevel !== null, function ($query) use ($scopeLevel) { - $query->whereHas('organization_scope', function ($q) use ($scopeLevel) { + $query->whereHas('organization_scope', function (Builder $q) use ($scopeLevel) { + /** @var Builder<\AuroraWebSoftware\AAuth\Models\OrganizationScope> $q */ $q->where('level', $scopeLevel); }); }) @@ -524,6 +552,9 @@ public function descendant(int $rootNodeId, int $childNodeId): bool })->exists(); } + /** + * @return array|null + */ public function ABACRules(string $modelType): ?array { return RoleModelAbacRule::where('role_id', '=', $this->role->id) diff --git a/src/Contracts/AAuthUserContract.php b/src/Contracts/AAuthUserContract.php index c6653d3..e5142d9 100644 --- a/src/Contracts/AAuthUserContract.php +++ b/src/Contracts/AAuthUserContract.php @@ -8,7 +8,7 @@ use Illuminate\Database\Eloquent\Relations\Pivot; /** - * @property-read int $id + * @property int $id */ interface AAuthUserContract { diff --git a/src/Events/PermissionAddedEvent.php b/src/Events/PermissionAddedEvent.php index 9a631f3..a9f2c57 100644 --- a/src/Events/PermissionAddedEvent.php +++ b/src/Events/PermissionAddedEvent.php @@ -11,6 +11,9 @@ class PermissionAddedEvent use Dispatchable; use SerializesModels; + /** + * @param array|null $parameters + */ public function __construct( public Role $role, public string $permission, diff --git a/src/Events/PermissionUpdatedEvent.php b/src/Events/PermissionUpdatedEvent.php index c3b71b4..3b39ebd 100644 --- a/src/Events/PermissionUpdatedEvent.php +++ b/src/Events/PermissionUpdatedEvent.php @@ -11,6 +11,10 @@ class PermissionUpdatedEvent use Dispatchable; use SerializesModels; + /** + * @param array|null $parameters + * @param array|null $oldParameters + */ public function __construct( public Role $role, public string $permission, diff --git a/src/Exceptions/AuthorizationException.php b/src/Exceptions/AuthorizationException.php index d61daee..d66dee4 100644 --- a/src/Exceptions/AuthorizationException.php +++ b/src/Exceptions/AuthorizationException.php @@ -9,7 +9,7 @@ class AuthorizationException extends Exception /** * All of the guards that were checked. * - * @var array + * @var list */ protected $guards; @@ -23,6 +23,7 @@ class AuthorizationException extends Exception /** * Create a new authentication exception. * + * @param list $guards * @param string|null $redirectTo * @return void */ @@ -37,7 +38,7 @@ public function __construct(string $message = 'Authorization Error', array $guar /** * Get the guards that were checked. * - * @return array + * @return list */ public function guards() { diff --git a/src/Exceptions/MissingRoleException.php b/src/Exceptions/MissingRoleException.php index 0c3a93b..012d7f5 100644 --- a/src/Exceptions/MissingRoleException.php +++ b/src/Exceptions/MissingRoleException.php @@ -9,7 +9,7 @@ class MissingRoleException extends Exception /** * All of the guards that were checked. * - * @var array + * @var list */ protected $guards; @@ -24,6 +24,7 @@ class MissingRoleException extends Exception * Create a new authentication exception. * * @param string|null $message + * @param list $guards * @param string|null $redirectTo * @return void */ @@ -58,7 +59,7 @@ protected function getDefaultMessage(): string /** * Get the guards that were checked. * - * @return array + * @return list */ public function guards() { diff --git a/src/Facades/AAuth.php b/src/Facades/AAuth.php index a01eb24..4e260f7 100644 --- a/src/Facades/AAuth.php +++ b/src/Facades/AAuth.php @@ -8,25 +8,21 @@ /** * AAuth Facade * - * @see AuroraWebSoftware + * @see \AuroraWebSoftware\AAuth\AAuth * - * @static switchableRoles(): array|Collection|\Illuminate\Support\Collection - * @static permissions(): \Illuminate\Support\Collection|array - * @static organizationPermissions(): \Illuminate\Support\Collection|array - * @static systemPermissions(): array|\Illuminate\Support\Collection - * @static organizationNodeIds(): array|null - * @static can(string $string) - * @static passOrAbort(string $string, string $message = 'No Permission') - * - * @method static organizationNodes(bool $includeRootNode = false, ?string $modelType = null): \Illuminate\Support\Collection - * - * @static organizationNode(int $nodeId, ?string $modelType = null): OrganizationNode|array|Collection|Model - * @static descendant(int $rootNodeId, int $childNodeId): bool - * @static switchableRolesStatic(int $userId): array|Collection|\Illuminate\Support\Collection - * - * @method static currentRole() \AuroraWebSoftware\AAuth\Models\Role|null - * @method static ABACRules(string $modelType) array|null - * @method static organizationNodesQuery(bool $includeRootNode = false, ?string $modelType = null): OrganizationNode|Builder + * @method static array|\Illuminate\Support\Collection switchableRoles() + * @method static array permissions() + * @method static array organizationPermissions() + * @method static array systemPermissions() + * @method static array|null organizationNodeIds() + * @method static bool can(string $permission, mixed ...$arguments) + * @method static void passOrAbort(string $permission, string $message = 'No Permission', array $arguments = []) + * @method static \Illuminate\Support\Collection organizationNodes(bool $includeRootNode = false, ?string $modelType = null) + * @method static \AuroraWebSoftware\AAuth\Models\OrganizationNode organizationNode(int $nodeId, ?string $modelType = null) + * @method static bool descendant(int $rootNodeId, int $childNodeId) + * @method static \AuroraWebSoftware\AAuth\Models\Role|null currentRole() + * @method static array|null ABACRules(string $modelType) + * @method static Builder<\AuroraWebSoftware\AAuth\Models\OrganizationNode> organizationNodesQuery(bool $includeRootNode = false, ?string $modelType = null) */ class AAuth extends Facade { diff --git a/src/Http/Requests/StoreOrganizationNodeRequest.php b/src/Http/Requests/StoreOrganizationNodeRequest.php index 7a0e373..45aeb22 100644 --- a/src/Http/Requests/StoreOrganizationNodeRequest.php +++ b/src/Http/Requests/StoreOrganizationNodeRequest.php @@ -7,6 +7,9 @@ class StoreOrganizationNodeRequest extends FormRequest { // todo + /** + * @var array + */ public static array $rules = [ 'name' => ['required', 'min:3'], 'parent_id' => ['required', 'int'], @@ -22,6 +25,8 @@ public function authorize(): bool /** * Get the validation rules that apply to the request. + * + * @return array */ public function rules(): array { diff --git a/src/Http/Requests/StoreOrganizationScopeRequest.php b/src/Http/Requests/StoreOrganizationScopeRequest.php index ef5f3a4..18d55a4 100644 --- a/src/Http/Requests/StoreOrganizationScopeRequest.php +++ b/src/Http/Requests/StoreOrganizationScopeRequest.php @@ -6,11 +6,17 @@ class StoreOrganizationScopeRequest extends FormRequest { + /** + * @var array + */ public static array $rules = [ 'name' => ['required', 'min:3'], 'level' => [], ]; + /** + * @return array + */ public static function getRules(): array { return self::$rules; @@ -21,6 +27,9 @@ public function authorize(): bool return true; } + /** + * @return array + */ public function rules(): array { return self::$rules; diff --git a/src/Http/Requests/StoreRoleRequest.php b/src/Http/Requests/StoreRoleRequest.php index f796ddb..6531570 100644 --- a/src/Http/Requests/StoreRoleRequest.php +++ b/src/Http/Requests/StoreRoleRequest.php @@ -7,6 +7,9 @@ class StoreRoleRequest extends FormRequest { // todo + /** + * @var array + */ public static array $rules = [ 'name' => ['required', 'min:3'], ]; @@ -21,6 +24,8 @@ public function authorize(): bool /** * Get the validation rules that apply to the request. + * + * @return array */ public function rules(): array { diff --git a/src/Http/Requests/UpdateOrganizationNodeRequest.php b/src/Http/Requests/UpdateOrganizationNodeRequest.php index 1fc0ed9..0070b4b 100644 --- a/src/Http/Requests/UpdateOrganizationNodeRequest.php +++ b/src/Http/Requests/UpdateOrganizationNodeRequest.php @@ -6,6 +6,9 @@ class UpdateOrganizationNodeRequest extends FormRequest { + /** + * @var array + */ public static array $rules = [ 'name' => ['required', 'min:3'], 'parent_id' => ['required', 'int'], @@ -21,6 +24,8 @@ public function authorize(): bool /** * Get the validation rules that apply to the request. + * + * @return array */ public function rules(): array { diff --git a/src/Http/Requests/UpdateOrganizationScopeRequest.php b/src/Http/Requests/UpdateOrganizationScopeRequest.php index ca1d730..591ed9f 100644 --- a/src/Http/Requests/UpdateOrganizationScopeRequest.php +++ b/src/Http/Requests/UpdateOrganizationScopeRequest.php @@ -6,6 +6,9 @@ class UpdateOrganizationScopeRequest extends FormRequest { + /** + * @var array + */ public static array $rules = [ 'name' => ['required', 'min:3'], 'level' => [], @@ -21,6 +24,8 @@ public function authorize(): bool /** * Get the validation rules that apply to the request. + * + * @return array */ public function rules(): array { diff --git a/src/Http/Requests/UpdateRoleRequest.php b/src/Http/Requests/UpdateRoleRequest.php index d926455..6369a67 100644 --- a/src/Http/Requests/UpdateRoleRequest.php +++ b/src/Http/Requests/UpdateRoleRequest.php @@ -6,6 +6,9 @@ class UpdateRoleRequest extends FormRequest { + /** + * @var array + */ public static array $rules = [ 'name' => [ 'required', @@ -23,6 +26,8 @@ public function authorize(): bool /** * Get the validation rules that apply to the request. + * + * @return array */ public function rules(): array { diff --git a/src/Interfaces/AAuthABACModelInterface.php b/src/Interfaces/AAuthABACModelInterface.php index 04f1b50..f6fdc2c 100644 --- a/src/Interfaces/AAuthABACModelInterface.php +++ b/src/Interfaces/AAuthABACModelInterface.php @@ -6,5 +6,8 @@ interface AAuthABACModelInterface { public static function getModelType(): string; + /** + * @return array + */ public static function getABACRules(): array; } diff --git a/src/Models/OrganizationNode.php b/src/Models/OrganizationNode.php index f60ee07..3b86bc7 100644 --- a/src/Models/OrganizationNode.php +++ b/src/Models/OrganizationNode.php @@ -53,7 +53,7 @@ class OrganizationNode extends Model protected $fillable = ['organization_scope_id', 'name', 'model_type', 'model_id', 'path', 'parent_id']; /** - * @return BelongsTo + * @return BelongsTo */ public function organization_scope(): BelongsTo { @@ -106,7 +106,7 @@ public function breadCrumbs(): \Illuminate\Support\Collection } /** - * @return MorphTo + * @return MorphTo */ public function relatedModel(): MorphTo { diff --git a/src/Models/OrganizationScope.php b/src/Models/OrganizationScope.php index a456f68..83c10ca 100644 --- a/src/Models/OrganizationScope.php +++ b/src/Models/OrganizationScope.php @@ -54,7 +54,7 @@ public function getDeletableAttribute(): bool } /** - * @return HasMany + * @return HasMany */ public function organization_nodes(): HasMany { diff --git a/src/Models/Role.php b/src/Models/Role.php index d9f1a97..7c1a3f1 100644 --- a/src/Models/Role.php +++ b/src/Models/Role.php @@ -50,7 +50,7 @@ public function permissions(): array /** * Get role permissions as HasMany relationship (v2) * - * @return HasMany + * @return HasMany */ public function rolePermissions(): HasMany { @@ -58,7 +58,7 @@ public function rolePermissions(): HasMany } /** - * @return HasMany + * @return HasMany */ public function abacRules(): HasMany { @@ -66,7 +66,7 @@ public function abacRules(): HasMany } /** - * @return BelongsTo + * @return BelongsTo */ public function organization_scope(): BelongsTo { @@ -74,7 +74,7 @@ public function organization_scope(): BelongsTo } /** - * @return BelongsToMany + * @return BelongsToMany */ public function organization_nodes(): BelongsToMany { @@ -83,6 +83,8 @@ public function organization_nodes(): BelongsToMany /** * Give a permission to this role + * + * @param array|null $parameters */ public function givePermission(string $permission, ?array $parameters = null): RolePermission { @@ -110,7 +112,7 @@ public function removePermission(string $permission): bool /** * Sync permissions for this role * - * @param array $permissions Array of permission strings or ['permission' => 'params'] pairs + * @param array $permissions Array of permission strings or ['permission' => 'params'] pairs */ public function syncPermissions(array $permissions): void { diff --git a/src/Models/RoleModelAbacRule.php b/src/Models/RoleModelAbacRule.php index 8e627b2..36fe203 100644 --- a/src/Models/RoleModelAbacRule.php +++ b/src/Models/RoleModelAbacRule.php @@ -12,7 +12,7 @@ * AuroraWebSoftware\AAuth\Models\Role * * @property int $id - * @property array $rules_json + * @property array $rules_json * @property string $model_type * @property int $role_id * @property Carbon|null $created_at diff --git a/src/Models/RolePermission.php b/src/Models/RolePermission.php index fc288cc..68e82bb 100644 --- a/src/Models/RolePermission.php +++ b/src/Models/RolePermission.php @@ -11,9 +11,9 @@ * @property-read int $id * @property int $role_id * @property string $permission - * @property array|null $parameters + * @property array|null $parameters * - * @method static RolePermission create(array $attributes) + * @method static RolePermission create(array $attributes) */ class RolePermission extends Model { @@ -34,7 +34,7 @@ class RolePermission extends Model /** * The attributes that are mass assignable. * - * @var array + * @var list */ protected $fillable = ['role_id', 'permission', 'parameters']; @@ -50,7 +50,7 @@ class RolePermission extends Model /** * Get the role that owns this permission. * - * @return BelongsTo + * @return BelongsTo */ public function role(): BelongsTo { diff --git a/src/Models/User.php b/src/Models/User.php index 8e9ef2d..246275a 100644 --- a/src/Models/User.php +++ b/src/Models/User.php @@ -98,7 +98,7 @@ public function rolesWithOrganizationNodes(): Collection } /** - * @return BelongsToMany + * @return BelongsToMany */ public function system_roles(): BelongsToMany { @@ -107,7 +107,7 @@ public function system_roles(): BelongsToMany } /** - * @return BelongsToMany + * @return BelongsToMany */ public function organization_roles(): BelongsToMany { diff --git a/src/Scopes/AAuthABACModelScope.php b/src/Scopes/AAuthABACModelScope.php index 74210f9..f0d4ee0 100644 --- a/src/Scopes/AAuthABACModelScope.php +++ b/src/Scopes/AAuthABACModelScope.php @@ -11,37 +11,24 @@ use Illuminate\Database\Eloquent\Scope; /** - * @template TModelClass of Model + * @implements Scope */ class AAuthABACModelScope implements Scope { /** - * @param Builder $builder + * @param Builder $builder + * @param Model&AAuthABACModelInterface $model * * @throws Exception */ public function apply(Builder $builder, Model $model, mixed $rules = false, string $parentOperator = '&&'): void { if ($rules === false) { - /** - * @var AAuthABACModelInterface $model - * - * PHPStan analysis does not return any errors, but it underlines the ABACRules method because it somehow - * does not see it, even though it is defined in the facade. - * - * @phpstan-ignore-next-line - */ $rules = AAuth::ABACRules($model::getModelType()) ?? []; - /** - * @var array $rules - */ ABACUtil::validateAbacRuleArray($rules); $builder->where(function ($query) use ($rules, $model) { - /** - * @var Model $model - */ $this->apply($query, $model, $rules); }); } else { @@ -63,7 +50,9 @@ public function apply(Builder $builder, Model $model, mixed $rules = false, stri /** * Apply logical operator (&& or ||) to the query builder. * - * @param Builder $builder + * @param Builder $builder + * @param array $abacRule + * @param Model&AAuthABACModelInterface $model * * @throws Exception */ @@ -79,7 +68,8 @@ protected function applyLogicalOperator(Builder $builder, array $abacRule, Model /** * Apply conditional operator to the query builder. * - * @param Builder $builder + * @param Builder $builder + * @param array $rule */ protected function applyConditionalOperator(Builder $builder, array $rule, string $parentOperator): void { diff --git a/src/Scopes/AAuthOrganizationNodeScope.php b/src/Scopes/AAuthOrganizationNodeScope.php index c2018fd..8ee971e 100644 --- a/src/Scopes/AAuthOrganizationNodeScope.php +++ b/src/Scopes/AAuthOrganizationNodeScope.php @@ -3,14 +3,19 @@ namespace AuroraWebSoftware\AAuth\Scopes; use AuroraWebSoftware\AAuth\Facades\AAuth; +use AuroraWebSoftware\AAuth\Interfaces\AAuthOrganizationNodeInterface; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Scope; +/** + * @implements Scope + */ class AAuthOrganizationNodeScope implements Scope { /** - * @param Builder $builder + * @param Builder $builder + * @param Model&AAuthOrganizationNodeInterface $model */ public function apply(Builder $builder, Model $model): void { @@ -37,6 +42,9 @@ protected function getFromTableName(mixed $from): string /** * Prefix the where column with the table name if needed + * + * @param array $where + * @return array */ protected function prefixWhereColumn(array $where, string $from): array { @@ -49,6 +57,8 @@ protected function prefixWhereColumn(array $where, string $from): array /** * Get the select columns for the query (only selects fields from the left table) + * + * @return array */ protected function getSelectColumns(string $from): array { diff --git a/src/Services/OrganizationService.php b/src/Services/OrganizationService.php index 470bafa..a04b77e 100644 --- a/src/Services/OrganizationService.php +++ b/src/Services/OrganizationService.php @@ -21,6 +21,7 @@ class OrganizationService /** * Creates an org. scope with given array * + * @param array $organizationScope * * @throws ValidationException */ @@ -41,6 +42,7 @@ public function createOrganizationScope(array $organizationScope, bool $withVali /** * Updates a Perm. * + * @param array $organizationScope * * @throws ValidationException */ diff --git a/src/Services/RolePermissionService.php b/src/Services/RolePermissionService.php index 8e5a342..dc46b3d 100644 --- a/src/Services/RolePermissionService.php +++ b/src/Services/RolePermissionService.php @@ -27,6 +27,7 @@ class RolePermissionService /** * Creates a Perm. with given array * + * @param array $role * * @throws ValidationException */ @@ -54,6 +55,8 @@ public function createRole(array $role, bool $withValidation = true): Role /** * Updates a Perm. + * + * @param array $role */ public function updateRole(array $role, int $id, bool $withValidation = true): ?Role { @@ -101,6 +104,9 @@ public function deactivateRole(int $roleId): bool return $roleId->save(); } + /** + * @param string|array $permissionOrPermissions + */ public function attachPermissionToRole(string|array $permissionOrPermissions, int $roleId): bool { $roleId = Role::find($roleId)->id; @@ -120,6 +126,9 @@ public function attachPermissionToRole(string|array $permissionOrPermissions, in return true; } + /** + * @param string|array $permissions + */ public function detachPermissionFromRole(string|array $permissions, int $roleId): bool { $roleId = Role::find($roleId)->id; @@ -150,6 +159,8 @@ public function detachAllPermissionsFromRole(int $roleId): bool } /** + * @param array $permissions + * * @throws Throwable */ public function syncPermissionsOfRole(array $permissions, int $roleId): bool @@ -166,7 +177,8 @@ public function syncPermissionsOfRole(array $permissions, int $roleId): bool } /** - * @param array $roleIdOrIds + * @param array|int $roleIdOrIds + * @return array * * @throws Throwable */ @@ -199,7 +211,7 @@ public function attachSystemRoleToUser(array|int $roleIdOrIds, int $userId): arr } /** - * @param int $roleIdOrIds + * @param array|int $roleIdOrIds * * @throws Throwable */ @@ -229,6 +241,10 @@ public function detachSystemRoleFromUser(array|int $roleIdOrIds, int $userId): i return $result; } + /** + * @param array $roleIds + * @return array + */ public function syncUserSystemRoles(int $userId, array $roleIds): array { // todo diff --git a/src/Utils/ABACUtil.php b/src/Utils/ABACUtil.php index f61c2e5..8f63310 100644 --- a/src/Utils/ABACUtil.php +++ b/src/Utils/ABACUtil.php @@ -9,6 +9,8 @@ class ABACUtil { /** + * @param array $abacRules + * * @throws Exception */ public static function validateAbacRuleArray(array $abacRules): void diff --git a/src/ZzTmpProbe.php b/src/ZzTmpProbe.php new file mode 100644 index 0000000..e0bbb57 --- /dev/null +++ b/src/ZzTmpProbe.php @@ -0,0 +1,25 @@ +id; + } +} diff --git a/src/helpers.php b/src/helpers.php index f655757..5556e1f 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -35,7 +35,7 @@ function aauth_active_role(): ?Role if (! function_exists('aauth_active_organization')) { function aauth_active_organization(): ?OrganizationNode { - $nodeIds = app('aauth')->organizationNodeIds(); + $nodeIds = aauth()->organizationNodeIds(); if (empty($nodeIds)) { return null; } From 619ba161677e16e344389c895134092dee6eb4b5 Mon Sep 17 00:00:00 2001 From: Emre Akay Date: Fri, 3 Jul 2026 22:11:45 +0300 Subject: [PATCH 11/20] ci: run the full suite against PostgreSQL in addition to MySQL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every matrix cell now installs pgsql/pdo_pgsql, boots a postgres:16 service, and runs pest a second time with DB_CONNECTION=pgsql — so all tests must pass on both MySQL and PostgreSQL (materialized-path LIKE, depth whereRaw, pgsql seed sequence and JSON columns are driver-sensitive). Co-Authored-By: Claude Fable 5 --- .github/workflows/run-tests.yml | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml index c4656ed..19d40c2 100644 --- a/.github/workflows/run-tests.yml +++ b/.github/workflows/run-tests.yml @@ -46,6 +46,15 @@ jobs: MYSQL_USER: aauth MYSQL_DATABASE: aauth options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 + postgres: + image: postgres:16 + ports: + - 5432 + env: + POSTGRES_PASSWORD: aauth + POSTGRES_USER: aauth + POSTGRES_DB: aauth + options: --health-cmd="pg_isready -U aauth" --health-interval=10s --health-timeout=5s --health-retries=5 steps: - name: Verify Mysql connection run: | @@ -60,7 +69,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} - extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo, http + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, pgsql, pdo_pgsql, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo, http coverage: none - name: Setup problem matchers @@ -73,9 +82,22 @@ jobs: composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update composer update --${{ matrix.stability }} --prefer-dist --no-interaction - - name: Execute tests + - name: Execute tests (MySQL) run: vendor/bin/pest --no-coverage env: + DB_CONNECTION: mysql + DB_HOST: 127.0.0.1 + DB_DATABASE: aauth DB_USERNAME: aauth DB_PASSWORD: aauth DB_PORT: ${{ job.services.mysql.ports[3306] }} + + - name: Execute tests (PostgreSQL) + run: vendor/bin/pest --no-coverage + env: + DB_CONNECTION: pgsql + DB_HOST: 127.0.0.1 + DB_DATABASE: aauth + DB_USERNAME: aauth + DB_PASSWORD: aauth + DB_PORT: ${{ job.services.postgres.ports[5432] }} From 53d0aa3bcf892742a10d90472f9a00b2cd5ca585 Mon Sep 17 00:00:00 2001 From: Emre Akay Date: Fri, 3 Jul 2026 22:13:58 +0300 Subject: [PATCH 12/20] fix(phpstan): specify generics on the switchableRoles facade @method Co-Authored-By: Claude Fable 5 --- src/Facades/AAuth.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Facades/AAuth.php b/src/Facades/AAuth.php index 4e260f7..dc3d971 100644 --- a/src/Facades/AAuth.php +++ b/src/Facades/AAuth.php @@ -10,7 +10,7 @@ * * @see \AuroraWebSoftware\AAuth\AAuth * - * @method static array|\Illuminate\Support\Collection switchableRoles() + * @method static array|\Illuminate\Support\Collection switchableRoles() * @method static array permissions() * @method static array organizationPermissions() * @method static array systemPermissions() From d8bf78f6568fe98f39f912cfd12b433345267f91 Mon Sep 17 00:00:00 2001 From: Emre Akay Date: Fri, 3 Jul 2026 22:26:37 +0300 Subject: [PATCH 13/20] refactor(phpstan): remove excludePaths + broad ignore, shrink baseline 25->8 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the PHPStan cleanup toward zero-suppression WITHOUT breaking changes: - Un-exclude both security scopes (AAuthOrganizationNodeScope, AAuthABACModelScope) — they are now fully analysed. - Remove the blanket `missingType.iterableValue` ignore and all inline @phpstan-ignore comments; the real array/relation/generic types are annotated instead (parallel pass across src). - Reduce the baseline from 25 entries to 8 (14 errors), each a genuine irreducible Larastan/library/query quirk documented in the baseline header (qualified-column where() on joins, $id contract access, library trait.unused, contract relation variance). Removing these would require breaking the public contract API, so they stay baselined. - Revert the exploratory `__get()` addition to AAuthUserContract (would have been a breaking interface change). phpstan clean, 155 tests green, php-cs-fixer clean. Co-Authored-By: Claude Fable 5 --- brainstorming.md | 14 ++++ phpstan-baseline.neon | 116 +++-------------------------- phpstan.neon.dist | 8 -- src/AAuth.php | 1 - src/Facades/AAuth.php | 6 +- src/Models/Role.php | 2 + src/Scopes/AAuthABACModelScope.php | 14 +++- src/ZzTmpProbe.php | 25 ------- src/helpers.php | 2 +- 9 files changed, 44 insertions(+), 144 deletions(-) create mode 100644 brainstorming.md delete mode 100644 src/ZzTmpProbe.php diff --git a/brainstorming.md b/brainstorming.md new file mode 100644 index 0000000..a909583 --- /dev/null +++ b/brainstorming.md @@ -0,0 +1,14 @@ +- postgress support. ve iyileştirme. +- yapay zekaya uygun hale getirme, laravel boost u kaldır +- testlerin tamamlanması tdd +- docs'lar ingizce olmalı. + + + + +burda yeni bir mode yapacağız. Bazı büyük sistemlerde bir çok rol oluştutulabiliyor. burda yetkilerde bir birleştirme yapabilmek isyirorum. belirli şartlara göre. +Örneğin bir okulda matematik ve geomateri öğretmeni diye 2 rol var. bu rolleri bazen birleştirebilmem lazım. öğretmen adında global bir kavram gibi. + +örneğin bir birleştirme kriteri ya da modu gib bir şey. ultrathink. düşüelim birlikte. + + diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 35b4207..63c8ce0 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,47 +1,28 @@ +# PHPStan level-7 baseline — ONLY genuine, irreducible Larastan/library/query quirks. +# Each of these would require a BREAKING change to remove, so they are baselined (not fixed): +# - property.notFound $id: AAuthUserContract exposes $id via the Eloquent model; PHPStan +# only honours it through a __get() interface method, which would break consumers. +# - argument.type (roles.id / uro.user_id): table/alias-qualified columns on joined +# queries are correct SQL but rejected by checkModelProperties (a false positive). +# - return.type User::roles(): the public contract types the relation with a concrete +# model, invariantly incompatible with the model's own $this. +# - trait.unused: AAuthUser/AAuthOrganizationNode/AAuthABACModel are PUBLIC library traits +# consumed by external models, so PHPStan sees zero uses inside src. +# Regenerate with: vendor/bin/phpstan analyse --generate-baseline parameters: ignoreErrors: - - - message: '#^Call to function method_exists\(\) with ''Illuminate\\\\Support\\\\Facades\\\\Schema'' and ''getIndexes'' will always evaluate to true\.$#' - identifier: function.alreadyNarrowedType - count: 1 - path: database/migrations/2024_01_01_000004_add_performance_indexes.php - - message: '#^Access to an undefined property AuroraWebSoftware\\AAuth\\Contracts\\AAuthUserContract\:\:\$id\.$#' identifier: property.notFound count: 4 path: src/AAuth.php - - - message: '#^Method AuroraWebSoftware\\AAuth\\AAuth\:\:getAccessibleOrganizationNodes\(\) return type with generic class Illuminate\\Support\\Collection does not specify its types\: TKey, TValue$#' - identifier: missingType.generics - count: 1 - path: src/AAuth.php - - - - message: '#^Method AuroraWebSoftware\\AAuth\\AAuth\:\:switchableRolesForCurrentPanel\(\) should return Illuminate\\Database\\Eloquent\\Collection\ but returns array\|Illuminate\\Support\\Collection\\.$#' - identifier: return.type - count: 1 - path: src/AAuth.php - - message: '#^Parameter \#1 \$column of method Illuminate\\Database\\Eloquent\\Builder\\:\:where\(\) expects array\\|\(Closure\(Illuminate\\Database\\Eloquent\\Builder\\)\: Illuminate\\Database\\Eloquent\\Builder\\)\|\(Closure\(Illuminate\\Database\\Eloquent\\Builder\\)\: void\)\|Illuminate\\Contracts\\Database\\Query\\Expression\|model property of AuroraWebSoftware\\AAuth\\Models\\Role, ''roles\.id'' given\.$#' identifier: argument.type count: 1 path: src/AAuth.php - - - message: '#^Parameter \#1 \$column of method Illuminate\\Database\\Eloquent\\Builder\\:\:where\(\) expects array\\|\(Closure\(Illuminate\\Database\\Eloquent\\Builder\\)\: Illuminate\\Database\\Eloquent\\Builder\\)\|\(Closure\(Illuminate\\Database\\Eloquent\\Builder\\)\: void\)\|Illuminate\\Contracts\\Database\\Query\\Expression\|model property of Illuminate\\Database\\Eloquent\\Model, ''level'' given\.$#' - identifier: argument.type - count: 1 - path: src/AAuth.php - - - - message: '#^Parameter \#1 \$column of method Illuminate\\Database\\Eloquent\\Builder\\:\:where\(\) expects array\\|\(Closure\(Illuminate\\Database\\Eloquent\\Builder\\)\: Illuminate\\Database\\Eloquent\\Builder\\)\|\(Closure\(Illuminate\\Database\\Eloquent\\Builder\\)\: void\)\|Illuminate\\Contracts\\Database\\Query\\Expression\|model property of Illuminate\\Database\\Eloquent\\Model, ''name'' given\.$#' - identifier: argument.type - count: 1 - path: src/AAuth.php - - message: '#^Parameter \#1 \$column of static method Illuminate\\Database\\Eloquent\\Builder\\:\:where\(\) expects array\\|\(Closure\(Illuminate\\Database\\Eloquent\\Builder\\)\: Illuminate\\Database\\Eloquent\\Builder\\)\|\(Closure\(Illuminate\\Database\\Eloquent\\Builder\\)\: void\)\|Illuminate\\Contracts\\Database\\Query\\Expression\|model property of AuroraWebSoftware\\AAuth\\Models\\Role, ''roles\.id'' given\.$#' identifier: argument.type @@ -51,81 +32,15 @@ parameters: - message: '#^Parameter \#1 \$column of static method Illuminate\\Database\\Eloquent\\Builder\\:\:where\(\) expects array\\|\(Closure\(Illuminate\\Database\\Eloquent\\Builder\\)\: Illuminate\\Database\\Eloquent\\Builder\\)\|\(Closure\(Illuminate\\Database\\Eloquent\\Builder\\)\: void\)\|Illuminate\\Contracts\\Database\\Query\\Expression\|model property of AuroraWebSoftware\\AAuth\\Models\\Role, ''uro\.user_id'' given\.$#' identifier: argument.type - count: 3 + count: 2 path: src/AAuth.php - - - message: '#^Method AuroraWebSoftware\\AAuth\\Models\\OrganizationNode\:\:organization_scope\(\) should return Illuminate\\Database\\Eloquent\\Relations\\BelongsTo\ but returns Illuminate\\Database\\Eloquent\\Relations\\BelongsTo\\.$#' - identifier: return.type - count: 1 - path: src/Models/OrganizationNode.php - - - - message: '#^Method AuroraWebSoftware\\AAuth\\Models\\OrganizationNode\:\:relatedModel\(\) should return Illuminate\\Database\\Eloquent\\Relations\\MorphTo\ but returns Illuminate\\Database\\Eloquent\\Relations\\MorphTo\\.$#' - identifier: return.type - count: 1 - path: src/Models/OrganizationNode.php - - - - message: '#^Method AuroraWebSoftware\\AAuth\\Models\\OrganizationScope\:\:organization_nodes\(\) should return Illuminate\\Database\\Eloquent\\Relations\\HasMany\ but returns Illuminate\\Database\\Eloquent\\Relations\\HasMany\\.$#' - identifier: return.type - count: 1 - path: src/Models/OrganizationScope.php - - - - message: '#^Method AuroraWebSoftware\\AAuth\\Models\\Role\:\:abacRules\(\) should return Illuminate\\Database\\Eloquent\\Relations\\HasMany\ but returns Illuminate\\Database\\Eloquent\\Relations\\HasMany\\.$#' - identifier: return.type - count: 1 - path: src/Models/Role.php - - - - message: '#^Method AuroraWebSoftware\\AAuth\\Models\\Role\:\:organization_nodes\(\) should return Illuminate\\Database\\Eloquent\\Relations\\BelongsToMany\ but returns Illuminate\\Database\\Eloquent\\Relations\\BelongsToMany\\.$#' - identifier: return.type - count: 1 - path: src/Models/Role.php - - - - message: '#^Method AuroraWebSoftware\\AAuth\\Models\\Role\:\:organization_scope\(\) should return Illuminate\\Database\\Eloquent\\Relations\\BelongsTo\ but returns Illuminate\\Database\\Eloquent\\Relations\\BelongsTo\\.$#' - identifier: return.type - count: 1 - path: src/Models/Role.php - - - - message: '#^Method AuroraWebSoftware\\AAuth\\Models\\Role\:\:rolePermissions\(\) should return Illuminate\\Database\\Eloquent\\Relations\\HasMany\ but returns Illuminate\\Database\\Eloquent\\Relations\\HasMany\\.$#' - identifier: return.type - count: 1 - path: src/Models/Role.php - - - - message: '#^Method AuroraWebSoftware\\AAuth\\Models\\RolePermission\:\:role\(\) should return Illuminate\\Database\\Eloquent\\Relations\\BelongsTo\ but returns Illuminate\\Database\\Eloquent\\Relations\\BelongsTo\\.$#' - identifier: return.type - count: 1 - path: src/Models/RolePermission.php - - - - message: '#^PHPDoc type array\ of property AuroraWebSoftware\\AAuth\\Models\\RolePermission\:\:\$fillable is not covariant with PHPDoc type list\ of overridden property Illuminate\\Database\\Eloquent\\Model\:\:\$fillable\.$#' - identifier: property.phpDocType - count: 1 - path: src/Models/RolePermission.php - - - - message: '#^Method AuroraWebSoftware\\AAuth\\Models\\User\:\:organization_roles\(\) should return Illuminate\\Database\\Eloquent\\Relations\\BelongsToMany\ but returns Illuminate\\Database\\Eloquent\\Relations\\BelongsToMany\\.$#' - identifier: return.type - count: 1 - path: src/Models/User.php - - message: '#^Method AuroraWebSoftware\\AAuth\\Models\\User\:\:roles\(\) should return Illuminate\\Database\\Eloquent\\Relations\\BelongsToMany\ but returns Illuminate\\Database\\Eloquent\\Relations\\BelongsToMany\\.$#' identifier: return.type count: 1 path: src/Models/User.php - - - message: '#^Method AuroraWebSoftware\\AAuth\\Models\\User\:\:system_roles\(\) should return Illuminate\\Database\\Eloquent\\Relations\\BelongsToMany\ but returns Illuminate\\Database\\Eloquent\\Relations\\BelongsToMany\\.$#' - identifier: return.type - count: 1 - path: src/Models/User.php - - message: '#^Trait AuroraWebSoftware\\AAuth\\Traits\\AAuthABACModel is used zero times and is not analysed\.$#' identifier: trait.unused @@ -143,10 +58,3 @@ parameters: identifier: trait.unused count: 1 path: src/Traits/AAuthUser.php - - - - message: '#^Function aauth_active_organization\(\) should return AuroraWebSoftware\\AAuth\\Models\\OrganizationNode\|null but returns AuroraWebSoftware\\AAuth\\Models\\OrganizationNode\|Illuminate\\Database\\Eloquent\\Collection\\|null\.$#' - identifier: return.type - count: 1 - path: src/helpers.php - diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 3959406..1291f7b 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -11,12 +11,4 @@ parameters: tmpDir: build/phpstan checkOctaneCompatibility: true checkModelProperties: true - ignoreErrors: - - identifier: missingType.iterableValue - -# ignoreErrors: -# - '#.*Internal error.*#' - excludePaths: - - src/Scopes/AAuthOrganizationNodeScope.php - - src/Scopes/AAuthABACModelScope.php diff --git a/src/AAuth.php b/src/AAuth.php index 79b04ed..f5274cd 100755 --- a/src/AAuth.php +++ b/src/AAuth.php @@ -223,7 +223,6 @@ protected function getCachedSwitchableRoles(): Collection */ protected function loadSwitchableRoles(): Collection { - // @phpstan-ignore-next-line return Role::where('uro.user_id', '=', $this->user->id) ->where('status', '=', 'active') ->leftJoin('user_role_organization_node as uro', 'uro.role_id', '=', 'roles.id') diff --git a/src/Facades/AAuth.php b/src/Facades/AAuth.php index dc3d971..2e077ee 100644 --- a/src/Facades/AAuth.php +++ b/src/Facades/AAuth.php @@ -11,9 +11,9 @@ * @see \AuroraWebSoftware\AAuth\AAuth * * @method static array|\Illuminate\Support\Collection switchableRoles() - * @method static array permissions() - * @method static array organizationPermissions() - * @method static array systemPermissions() + * @method static array permissions() + * @method static array organizationPermissions() + * @method static array systemPermissions() * @method static array|null organizationNodeIds() * @method static bool can(string $permission, mixed ...$arguments) * @method static void passOrAbort(string $permission, string $message = 'No Permission', array $arguments = []) diff --git a/src/Models/Role.php b/src/Models/Role.php index 7c1a3f1..700a027 100644 --- a/src/Models/Role.php +++ b/src/Models/Role.php @@ -40,6 +40,8 @@ class Role extends Model /** * Get permissions as array (legacy method - backward compatible) + * + * @return array */ public function permissions(): array { diff --git a/src/Scopes/AAuthABACModelScope.php b/src/Scopes/AAuthABACModelScope.php index f0d4ee0..b8115c8 100644 --- a/src/Scopes/AAuthABACModelScope.php +++ b/src/Scopes/AAuthABACModelScope.php @@ -68,7 +68,7 @@ protected function applyLogicalOperator(Builder $builder, array $abacRule, Model /** * Apply conditional operator to the query builder. * - * @param Builder $builder + * @param Builder $builder * @param array $rule */ protected function applyConditionalOperator(Builder $builder, array $rule, string $parentOperator): void @@ -77,7 +77,7 @@ protected function applyConditionalOperator(Builder $builder, array $rule, strin $queryMethod = $parentOperator == '||' ? 'orWhere' : 'where'; - $from = sprintf('%s.', is_string($builder->getQuery()->from) ? $builder->getQuery()->from : ''); + $from = $this->getFromPrefix($builder); $builder->{$queryMethod}( $from.$rule[$operator]['attribute'], @@ -85,4 +85,14 @@ protected function applyConditionalOperator(Builder $builder, array $rule, strin $rule[$operator]['value'] ); } + + /** + * Get the "from" table prefix (e.g. "table_name.") used to qualify rule columns. + * + * @param Builder $builder + */ + protected function getFromPrefix(Builder $builder): string + { + return sprintf('%s.', is_string($builder->getQuery()->from) ? $builder->getQuery()->from : ''); + } } diff --git a/src/ZzTmpProbe.php b/src/ZzTmpProbe.php deleted file mode 100644 index e0bbb57..0000000 --- a/src/ZzTmpProbe.php +++ /dev/null @@ -1,25 +0,0 @@ -id; - } -} diff --git a/src/helpers.php b/src/helpers.php index 5556e1f..f7c4676 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -40,7 +40,7 @@ function aauth_active_organization(): ?OrganizationNode return null; } - return OrganizationNode::find($nodeIds[0]); + return OrganizationNode::find((int) $nodeIds[0]); } } From 4e8273bea4850cf2f2c61eb4685f6b2cdabbb6d7 Mon Sep 17 00:00:00 2001 From: Emre Akay Date: Fri, 3 Jul 2026 22:34:08 +0300 Subject: [PATCH 14/20] ci(phpstan): run on PHP 8.3 to match the Laravel 13 lock The phpstan job pinned PHP 8.2, but composer.lock resolves laravel/framework 13 (which requires PHP 8.3+). On 8.2 the analysis saw a non-generic Eloquent Scope and rejected the `@implements Scope<...>` docblocks. Running on 8.3 installs the locked Laravel 13, so Scope is generic and phpstan matches local. Co-Authored-By: Claude Fable 5 --- .github/workflows/phpstan.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml index 70f9c9f..4f15b6f 100644 --- a/.github/workflows/phpstan.yml +++ b/.github/workflows/phpstan.yml @@ -20,7 +20,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.2' + php-version: '8.3' coverage: none - name: Install composer dependencies From 7bf393082a72b4791307c52575e645ffa7d5f714 Mon Sep 17 00:00:00 2001 From: Emre Akay Date: Fri, 3 Jul 2026 22:57:06 +0300 Subject: [PATCH 15/20] refactor(phpstan): remove all 4 inline @phpstan-ignore comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 2 were spurious (collect() calls) — removed cleanly. - new AAuth(Auth::user(), ...): annotate the resolved user as AAuthUserContract|null. - Role::$organizationNode: type the @property nullable (find() may return null) + cast the id to int so the assignment is well-typed. src now contains ZERO inline suppressions; the only remaining suppression is the documented 8-entry baseline of irreducible Larastan/library/query quirks. phpstan clean, 155 tests green, php-cs-fixer clean. Co-Authored-By: Claude Fable 5 --- phpstan-baseline.neon | 17 +++++++++-------- src/AAuthServiceProvider.php | 8 ++++---- src/Models/OrganizationNode.php | 1 - src/Models/Role.php | 2 +- src/Models/User.php | 9 ++------- 5 files changed, 16 insertions(+), 21 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 63c8ce0..69f8e4f 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,13 +1,14 @@ # PHPStan level-7 baseline — ONLY genuine, irreducible Larastan/library/query quirks. -# Each of these would require a BREAKING change to remove, so they are baselined (not fixed): -# - property.notFound $id: AAuthUserContract exposes $id via the Eloquent model; PHPStan -# only honours it through a __get() interface method, which would break consumers. -# - argument.type (roles.id / uro.user_id): table/alias-qualified columns on joined -# queries are correct SQL but rejected by checkModelProperties (a false positive). -# - return.type User::roles(): the public contract types the relation with a concrete +# There are NO excludePaths, NO blanket ignoreErrors, and NO inline @phpstan-ignore in src. +# Each remaining entry would require a BREAKING change (or reduced Larastan strictness) to fix: +# - property.notFound $id (x4): AAuthUserContract exposes Eloquent's $id; PHPStan only +# honours it via a __get() interface method, which would break consumers. +# - argument.type roles.id / uro.user_id (x6): table/alias-qualified columns on JOINed +# queries are correct SQL but rejected by checkModelProperties (false positive). +# - return.type User::roles() (x1): the public contract types the relation with a concrete # model, invariantly incompatible with the model's own $this. -# - trait.unused: AAuthUser/AAuthOrganizationNode/AAuthABACModel are PUBLIC library traits -# consumed by external models, so PHPStan sees zero uses inside src. +# - trait.unused (x3): AAuthUser/AAuthOrganizationNode/AAuthABACModel are PUBLIC library +# traits consumed by external models, so PHPStan sees zero uses inside src. # Regenerate with: vendor/bin/phpstan analyse --generate-baseline parameters: ignoreErrors: diff --git a/src/AAuthServiceProvider.php b/src/AAuthServiceProvider.php index b7960d5..040448b 100644 --- a/src/AAuthServiceProvider.php +++ b/src/AAuthServiceProvider.php @@ -61,10 +61,10 @@ public function boot(): void // classic PHP-FPM each request is a fresh process, so scoped() and singleton() // are observationally equivalent (no behaviour change for PHP-FPM consumers). $this->app->scoped('aauth', function ($app) { - return new AAuth( - Auth::user(), // @phpstan-ignore-line - Session::get('roleId') - ); + /** @var \AuroraWebSoftware\AAuth\Contracts\AAuthUserContract|null $user */ + $user = Auth::user(); + + return new AAuth($user, Session::get('roleId')); }); Gate::before(function ($user, $ability, $arguments = []) { diff --git a/src/Models/OrganizationNode.php b/src/Models/OrganizationNode.php index 3b86bc7..45969b4 100644 --- a/src/Models/OrganizationNode.php +++ b/src/Models/OrganizationNode.php @@ -96,7 +96,6 @@ public function availableScopes(): Collection public function breadCrumbs(): \Illuminate\Support\Collection { $pathNodeIds = explode('/', $this->path); - /* @phpstan-ignore-next-line */ $breadCrumbs = collect(); foreach ($pathNodeIds as $pathNodeId) { $breadCrumbs->push(OrganizationNode::findOrFail($pathNodeId)); diff --git a/src/Models/Role.php b/src/Models/Role.php index 700a027..a5320bf 100644 --- a/src/Models/Role.php +++ b/src/Models/Role.php @@ -19,7 +19,7 @@ * @property string|null $type * @property string $name * @property string $status - * @property OrganizationNode $organizationNode + * @property ?OrganizationNode $organizationNode * @property Carbon|null $created_at * @property Carbon|null $updated_at * @property int|null $organization_scope_id diff --git a/src/Models/User.php b/src/Models/User.php index 246275a..2e54540 100644 --- a/src/Models/User.php +++ b/src/Models/User.php @@ -77,19 +77,14 @@ public function roles(): BelongsToMany */ public function rolesWithOrganizationNodes(): Collection { - // @phpstan-ignore-next-line $rolesCollection = collect(); $rolesWithOrganizationNodes = DB::table('user_role_organization_node')->where('user_id', '=', $this->id)->get(); foreach ($rolesWithOrganizationNodes as $rolesWithOrganizationNode) { $role = Role::find($rolesWithOrganizationNode->role_id); - /** - * @var Role $role - * - * @phpstan-ignore-next-line - */ - $role->organizationNode = OrganizationNode::find($rolesWithOrganizationNode->organization_node_id); + /** @var Role $role */ + $role->organizationNode = OrganizationNode::find((int) $rolesWithOrganizationNode->organization_node_id); $rolesCollection->push($role); } From 947c52684405e28187fcd5e8e567d7aab6673445 Mon Sep 17 00:00:00 2001 From: Emre Akay Date: Fri, 3 Jul 2026 23:00:09 +0300 Subject: [PATCH 16/20] chore(agent): add LA11 cache-security + LA12 Octane checks to laravel-architect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pre-PR laravel-architect agent now continuously reviews: - LA11 Cache security: every cache key must encode the FULL security context (user + active role + scope + permission) — flags cross-user data bleed, cross-role privilege escalation, and stale-allow-after-revoke as BLOCKERs; also store/tags/TTL correctness. - LA12 Octane: scoped() (not singleton()) binding, no request-surviving static or per-user mutable state (no A->B bleed on a shared worker). Plus two new red-lines (cache authorization leak; Octane state-bleed). Co-Authored-By: Claude Fable 5 --- .claude/agents/laravel-architect.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.claude/agents/laravel-architect.md b/.claude/agents/laravel-architect.md index 56035e9..3e29b2c 100644 --- a/.claude/agents/laravel-architect.md +++ b/.claude/agents/laravel-architect.md @@ -33,16 +33,20 @@ The maintainer wants this package **LEAN, SIMPLE, USABLE**. Your bias: fewer con | LA8 | BC | No existing public signature changed in a breaking way; if behavior changes, it is disclosed (CHANGELOG/UPGRADE) → HIGH | diff public methods + CHANGELOG.md | | LA9 | Idiom | Idiomatic Laravel (Eloquent/relations/scopes/events used correctly; no reinventing framework features) → LOW | inspect | | LA10 | Config-less | No new hardcoded model/table string where the pattern should be reused; but do **not** introduce a config system just for this → LOW | grep string literals | +| LA11 | **Cache security** | **Every cache key MUST encode the FULL security context it caches for** — user id **and** active role id **and** org-node/scope **and** permission+args. A key missing any dimension is a **DATA LEAK / privilege-escalation** vector, e.g.: (a) **cross-user bleed** — a key without the user id (`"switchable_roles"` instead of `"…:user:{id}"`) serves User A's roles/permissions/ABAC to User B; (b) **cross-role escalation** — a key without the active role serves a high-privilege role's cached `allow` to the same user on a lower role; (c) **stale allow** — an authz write (role/permission/ABAC/org-node/assignment) not matched by a cache bust keeps granting a **revoked** permission. Also: honours the **configured store** (never hardcodes `default`); no cache **tags** on tag-less stores (array/file/database throw); per-request `requestCache` keyed by the same full context and never shared across users. → **BLOCKER** on any cross-user/cross-role bleed or a stale *allow* | grep `Cache::`, `cache(`, `clearUserRoleCache`, `getPermissionCacheKey`, `remember`, observers; for EACH key list which context dims it includes and prove no other user/role can hit it | +| LA12 | **Octane** | Safe under long-lived workers (Swoole/RoadRunner): the `aauth` binding is **`scoped()` not `singleton()`**; **NO static/global mutable state** that survives a request (static props, static caches, `Gate::before` closures capturing per-user state); per-instance state (`requestCache`, `organizationNodeIds`, super-admin flag, current role) **resets per request** so User A cannot bleed into User B on the same worker; `Context::`/request-scoped storage used correctly; `Auth::user()`/`config()` not resolved-and-frozen at boot → **BLOCKER** if per-user state can leak across requests | grep `singleton(`, `scoped(`, `static `, `Context::`, `AAuthServiceProvider`, `switchableRolesStatic` | ## AAuth red-lines (BLOCK the PR) - A **new config flag** added to gate a bug fix or security behavior (fix it directly, secure-by-default). - A **new abstraction layer / authz engine** (e.g. a `saving()`/`deleting()` write-validation engine) where a **one-line guard** suffices. - A public API **signature break** without disclosure. +- A **cache authorization leak**: a cache key missing part of the security context (user / active role / scope) so one user's or role's cached *allow* is served to another — **cross-user data bleed** or **privilege escalation** — OR an authz write that does not bust a cached *allow* (revoked permission/ABAC still granted from cache). +- An **Octane state-bleed**: `singleton()` on `aauth`, or any per-user mutable state that survives the request boundary on a shared worker. ## Output format (emit exactly this) ``` ### 🏛️ [laravel-architect] verdict: APPROVE | CHANGES_REQUESTED | BLOCK -. Checklist: X/10 passed. +. Checklist: X/12 passed. | # | Severity | Category | Finding | Location | Fix | |---|----------|----------|---------|----------|-----| From 3b42255c6fab6b1a3b72e982f10bca353bcc9218 Mon Sep 17 00:00:00 2001 From: Emre Akay Date: Fri, 3 Jul 2026 23:24:24 +0300 Subject: [PATCH 17/20] refactor: remove the persistent cache subsystem; hold auth context per-instance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cache was over-engineering for this package (architect C1): the role cache re-loaded the exact relations it eager-loaded, so it guarded ~1 query/request behind a whole invalidation apparatus, plus dead forget() keys and a multi-tenant key-collision risk. Removed entirely: - getCachedRole/getCachedSwitchableRoles + the aauth-advanced.cache config - the RoleObserver/RolePermissionObserver cache-forget + RolePermissionService clearUserRoleCache (observers keep only event dispatch + live-context refresh) Per-request loading is now the ONLY mechanism and it is correct on both PHP-FPM and Octane: the scoped('aauth') instance loads role+permissions+ABAC ONCE in the constructor (loadRole eager-load; loadMissing() drops the redundant re-query) and every can() reads from that in-memory context — no per-check DB query. Security: the auth context now lives on the request-scoped INSTANCE, not in the shared Context store under a fixed 'aauth_context' key. Two AAuth instances in one request (e.g. impersonation / a job over several users) can no longer overwrite and read each other's permissions. New regression test proves no cross-user leak; the Octane scoping test still passes. 153 tests green, phpstan clean, php-cs-fixer clean. Co-Authored-By: Claude Fable 5 --- config/aauth-advanced.php | 21 ----- src/AAuth.php | 79 +++++------------- src/Observers/RoleObserver.php | 29 ------- src/Observers/RolePermissionObserver.php | 29 ++----- src/Services/RolePermissionService.php | 30 ------- tests/Unit/RolePermissionServiceTest.php | 25 ------ tests/Unit/V2/V2FeaturesTest.php | 102 +++++++++-------------- 7 files changed, 68 insertions(+), 247 deletions(-) diff --git a/config/aauth-advanced.php b/config/aauth-advanced.php index 4ad26dc..824c664 100644 --- a/config/aauth-advanced.php +++ b/config/aauth-advanced.php @@ -27,25 +27,4 @@ 'enabled' => false, 'column' => 'is_super_admin', ], - - /* - |-------------------------------------------------------------------------- - | Cache Configuration - |-------------------------------------------------------------------------- - | - | Cache settings for role and permission data. When enabled, role data - | and switchable roles are cached for better performance. - | - | Cache is automatically invalidated when: - | - Role is updated/deleted (RoleObserver) - | - Permission is added/updated/removed (RolePermissionObserver) - | - User role assignment changes (RolePermissionService) - | - */ - 'cache' => [ - 'enabled' => true, - 'ttl' => 3600, // 1 hour - 'prefix' => 'aauth', - 'store' => null, // null = default cache driver - ], ]; diff --git a/src/AAuth.php b/src/AAuth.php index f5274cd..d171ebf 100755 --- a/src/AAuth.php +++ b/src/AAuth.php @@ -13,8 +13,6 @@ use Illuminate\Auth\AuthenticationException; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; -use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\Context; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use Symfony\Component\HttpFoundation\Response as ResponseAlias; @@ -22,8 +20,6 @@ class AAuth { - private const CONTEXT_KEY = 'aauth_context'; - public AAuthUserContract $user; public Role $role; @@ -38,6 +34,15 @@ class AAuth */ protected array $requestCache = []; + /** + * Per-request authorization context (role, permissions, ABAC, org nodes, super-admin), + * held on THIS request-scoped instance — never in shared/global storage — so two AAuth + * instances (e.g. two users in one request) can never read each other's permissions. + * + * @var array|null + */ + protected ?array $context = null; + /** * @throws Throwable */ @@ -55,9 +60,7 @@ public function __construct(?AAuthUserContract $user, ?int $roleId) $this->user = $user; - $this->role = config('aauth-advanced.cache.enabled', false) - ? $this->getCachedRole($roleId) - : $this->loadRole($roleId); + $this->role = $this->loadRole($roleId); throw_unless($this->role, new MissingRoleException()); @@ -80,10 +83,6 @@ public function currentRole(): ?Role */ public function switchableRoles(): array|Collection|\Illuminate\Support\Collection { - if (config('aauth-advanced.cache.enabled', false)) { - return $this->getCachedSwitchableRoles(); - } - return $this->loadSwitchableRoles(); } @@ -170,24 +169,6 @@ protected function hasRolesTypeColumn(): bool return $hasType; } - /** - * Get cached role with permissions and ABAC rules - */ - protected function getCachedRole(int $roleId): ?Role - { - $prefix = config('aauth-advanced.cache.prefix', 'aauth'); - $ttl = config('aauth-advanced.cache.ttl', 3600); - $store = config('aauth-advanced.cache.store'); - - $cacheKey = "{$prefix}:role:{$roleId}"; - - $cache = $store ? Cache::store($store) : Cache::store(); - - return $cache->remember($cacheKey, $ttl, function () use ($roleId) { - return $this->loadRole($roleId); - }); - } - /** * Load role from database with permissions and ABAC rules */ @@ -196,26 +177,6 @@ protected function loadRole(int $roleId): ?Role return Role::with(['rolePermissions', 'abacRules'])->find($roleId); } - /** - * Get cached switchable roles for current user - * - * @return Collection - */ - protected function getCachedSwitchableRoles(): Collection - { - $prefix = config('aauth-advanced.cache.prefix', 'aauth'); - $ttl = config('aauth-advanced.cache.ttl', 3600); - $store = config('aauth-advanced.cache.store'); - - $cacheKey = "{$prefix}:user:{$this->user->id}:switchable_roles"; - - $cache = $store ? Cache::store($store) : Cache::store(); - - return $cache->remember($cacheKey, $ttl, function () { - return $this->loadSwitchableRoles(); - }); - } - /** * Load switchable roles from database * @@ -278,8 +239,10 @@ public function isSuperAdmin(): bool protected function loadAndCacheContext(): void { - // Refresh relationships from database to get latest data - $this->role->load(['rolePermissions', 'abacRules']); + // Reuse the relations eager-loaded in loadRole() — loadMissing() avoids a + // redundant re-query on the constructor path; the mid-request refresh() in + // getAuthContext() reloads them first when data may have changed. + $this->role->loadMissing(['rolePermissions', 'abacRules']); $permissions = []; foreach ($this->role->rolePermissions as $rp) { @@ -307,7 +270,7 @@ protected function loadAndCacheContext(): void 'is_super_admin' => $isSuperAdmin, ]; - Context::addHidden(self::CONTEXT_KEY, $context); + $this->context = $context; } /** @@ -315,22 +278,20 @@ protected function loadAndCacheContext(): void */ protected function getAuthContext(): array { - $context = Context::getHidden(self::CONTEXT_KEY); - - if ($context === null) { - // Refresh role to get latest data in case of mid-request updates + if ($this->context === null) { + // Rebuild after a mid-request invalidation (clearContext): refresh the role so + // a permission/ABAC change made during this request is reflected. $this->role->refresh(); $this->loadAndCacheContext(); - $context = Context::getHidden(self::CONTEXT_KEY); } - return $context; + return $this->context; } public function clearContext(): void { $this->requestCache = []; - Context::forgetHidden(self::CONTEXT_KEY); + $this->context = null; } /** diff --git a/src/Observers/RoleObserver.php b/src/Observers/RoleObserver.php index 8909329..376340b 100644 --- a/src/Observers/RoleObserver.php +++ b/src/Observers/RoleObserver.php @@ -6,8 +6,6 @@ use AuroraWebSoftware\AAuth\Events\RoleDeletedEvent; use AuroraWebSoftware\AAuth\Events\RoleUpdatedEvent; use AuroraWebSoftware\AAuth\Models\Role; -use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\DB; class RoleObserver { @@ -18,38 +16,11 @@ public function created(Role $role): void public function updated(Role $role): void { - $this->clearRoleCache($role); event(new RoleUpdatedEvent($role)); } public function deleted(Role $role): void { - $this->clearRoleCache($role); event(new RoleDeletedEvent($role)); } - - protected function clearRoleCache(Role $role): void - { - if (! config('aauth-advanced.cache.enabled', false)) { - return; - } - - $prefix = config('aauth-advanced.cache.prefix', 'aauth'); - $store = config('aauth-advanced.cache.store'); - $cache = $store ? Cache::store($store) : Cache::store(); - - $cache->forget("{$prefix}:role:{$role->id}"); - $cache->forget("{$prefix}:role:{$role->id}:permissions"); - $cache->forget("{$prefix}:role:{$role->id}:abac_rules"); - - // Clear switchable roles cache for all users with this role - $userIds = DB::table('user_role_organization_node') - ->where('role_id', $role->id) - ->distinct() - ->pluck('user_id'); - - foreach ($userIds as $userId) { - $cache->forget("{$prefix}:user:{$userId}:switchable_roles"); - } - } } diff --git a/src/Observers/RolePermissionObserver.php b/src/Observers/RolePermissionObserver.php index c064219..2452b86 100644 --- a/src/Observers/RolePermissionObserver.php +++ b/src/Observers/RolePermissionObserver.php @@ -6,14 +6,12 @@ use AuroraWebSoftware\AAuth\Events\PermissionRemovedEvent; use AuroraWebSoftware\AAuth\Events\PermissionUpdatedEvent; use AuroraWebSoftware\AAuth\Models\RolePermission; -use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\Context; class RolePermissionObserver { public function created(RolePermission $permission): void { - $this->clearPermissionCache($permission); + $this->clearAAuthContext(); event(new PermissionAddedEvent( $permission->role, $permission->permission, @@ -23,7 +21,7 @@ public function created(RolePermission $permission): void public function updated(RolePermission $permission): void { - $this->clearPermissionCache($permission); + $this->clearAAuthContext(); event(new PermissionUpdatedEvent( $permission->role, $permission->permission, @@ -34,34 +32,25 @@ public function updated(RolePermission $permission): void public function deleted(RolePermission $permission): void { - $this->clearPermissionCache($permission); + $this->clearAAuthContext(); event(new PermissionRemovedEvent( $permission->role, $permission->permission )); } - protected function clearPermissionCache(RolePermission $permission): void + /** + * Refresh the live AAuth request context so a permission change is + * reflected within the same request. + */ + protected function clearAAuthContext(): void { - // Clear AAuth instance's request cache and context try { if (app()->bound('aauth')) { app('aauth')->clearContext(); } } catch (\Throwable $e) { - // AAuth not initialized yet, just clear context directly - Context::forgetHidden('aauth_context'); + // No resolvable AAuth instance (seeder/console/queue) — nothing to clear. } - - if (! config('aauth-advanced.cache.enabled', false)) { - return; - } - - $prefix = config('aauth-advanced.cache.prefix', 'aauth'); - $store = config('aauth-advanced.cache.store'); - $cache = $store ? Cache::store($store) : Cache::store(); - - $cache->forget("{$prefix}:role:{$permission->role_id}"); - $cache->forget("{$prefix}:role:{$permission->role_id}:permissions"); } } diff --git a/src/Services/RolePermissionService.php b/src/Services/RolePermissionService.php index dc46b3d..8233dec 100644 --- a/src/Services/RolePermissionService.php +++ b/src/Services/RolePermissionService.php @@ -11,7 +11,6 @@ use AuroraWebSoftware\AAuth\Models\Role; use AuroraWebSoftware\AAuth\Models\RolePermission; use AuroraWebSoftware\AAuth\Models\User; -use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use Illuminate\Support\Facades\Validator; @@ -204,9 +203,6 @@ public function attachSystemRoleToUser(array|int $roleIdOrIds, int $userId): arr $result = User::find($userId)->system_roles()->sync($roleIdOrIds, false); - // Clear user's switchable_roles cache - $this->clearUserRoleCache($userId); - return $result; } @@ -236,8 +232,6 @@ public function detachSystemRoleFromUser(array|int $roleIdOrIds, int $userId): i $result = User::find($userId)->system_roles()->detach($roleIdOrIds); - $this->clearUserRoleCache($userId); - return $result; } @@ -251,8 +245,6 @@ public function syncUserSystemRoles(int $userId, array $roleIds): array // to be unit tested $result = User::find($userId)->system_roles()->sync($roleIds); - $this->clearUserRoleCache($userId); - return $result; } @@ -290,8 +282,6 @@ public function attachOrganizationRoleToUser(int $organizationNodeId, int $roleI 'organization_node_id' => $organizationNodeId, ]); - $this->clearUserRoleCache($userId); - return $result; } @@ -331,8 +321,6 @@ public function detachOrganizationRoleFromUser(int $userId, int $roleId, int $or 'organization_node_id' => $organizationNodeId, ]) ->delete(); - $this->clearUserRoleCache($userId); - return $result; // todo attach ve sync ile olmayacak gibi direk db query yazmank lazım } @@ -373,8 +361,6 @@ public function detachOrganizationRoleFromUserBy(int $organizationNodeId, int $r ]) ->delete(); - $this->clearUserRoleCache($userId); - return $result; } @@ -392,9 +378,6 @@ protected function hasRolesTypeColumn(): bool return $hasType; } - /** - * Clear user's role-related cache - */ /** * Enforce the active role's org-subtree boundary on role assignment, but only when * an AAuth context is resolvable (seeders/console/queue run without one and skip). @@ -417,17 +400,4 @@ protected function assertOrganizationNodeAuthorized(int $nodeId): void $aauth->organizationNode($nodeId); } - - protected function clearUserRoleCache(int $userId): void - { - if (! config('aauth-advanced.cache.enabled', false)) { - return; - } - - $prefix = config('aauth-advanced.cache.prefix', 'aauth'); - $store = config('aauth-advanced.cache.store'); - $cache = $store ? Cache::store($store) : Cache::store(); - - $cache->forget("{$prefix}:user:{$userId}:switchable_roles"); - } } diff --git a/tests/Unit/RolePermissionServiceTest.php b/tests/Unit/RolePermissionServiceTest.php index f59b2ab..fce95a4 100644 --- a/tests/Unit/RolePermissionServiceTest.php +++ b/tests/Unit/RolePermissionServiceTest.php @@ -220,31 +220,6 @@ $this->service->detachOrganizationRoleFromUserBy(99999, $role->id, 1); })->throws(\AuroraWebSoftware\AAuth\Exceptions\InvalidOrganizationNodeException::class); -test('detachOrganizationRoleFromUserBy clears user role cache when enabled', function () { - config(['aauth-advanced.cache.enabled' => true]); - config(['aauth-advanced.cache.prefix' => 'aauth']); - - $organizationScope = OrganizationScope::whereName('Root Scope')->first(); - $organizationNode = OrganizationNode::whereName('Root Node')->first(); - - $role = $this->service->createRole([ - 'organization_scope_id' => $organizationScope->id, - 'type' => 'organization', - 'name' => 'Cache Test Detach Role', - 'status' => 'active', - ]); - - $this->service->attachOrganizationRoleToUser($organizationNode->id, $role->id, 1); - - // Prime the cache - \Illuminate\Support\Facades\Cache::put('aauth:user:1:switchable_roles', ['sentinel'], 60); - expect(\Illuminate\Support\Facades\Cache::get('aauth:user:1:switchable_roles'))->toBe(['sentinel']); - - $this->service->detachOrganizationRoleFromUserBy($organizationNode->id, $role->id, 1); - - // The detach must have invalidated the cache key - expect(\Illuminate\Support\Facades\Cache::get('aauth:user:1:switchable_roles'))->toBeNull(); -}); test('both detach methods coexist on the service class', function () { $reflection = new \ReflectionClass(RolePermissionService::class); diff --git a/tests/Unit/V2/V2FeaturesTest.php b/tests/Unit/V2/V2FeaturesTest.php index 3ff536f..4d0f2c4 100644 --- a/tests/Unit/V2/V2FeaturesTest.php +++ b/tests/Unit/V2/V2FeaturesTest.php @@ -6,8 +6,7 @@ use AuroraWebSoftware\AAuth\Models\RolePermission; use AuroraWebSoftware\AAuth\Models\User; use Illuminate\Support\Facades\Artisan; -use Illuminate\Support\Facades\Cache; -use Illuminate\Support\Facades\Context; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; beforeEach(function () { @@ -22,54 +21,69 @@ |-------------------------------------------------------------------------- */ -test('context is loaded and cached on AAuth instantiation', function () { +test('permissions are loaded once on instantiation and served from the instance', function () { $user = User::find(1); $role = Role::whereName('Root Role 1')->first(); $aauth = new AAuth($user, $role->id); - // Context should be stored - $context = Context::getHidden('aauth_context'); - - expect($context)->not->toBeNull() - ->and($context['user_id'])->toBe($user->id) - ->and($context['role_id'])->toBe($role->id) - ->and($context['permissions'])->toBeArray(); + // Context (role + permissions) is held on THIS instance; checks resolve from it + // without re-querying per call. + expect($aauth->currentRole()->id)->toBe($role->id); + expect($aauth->can('create_something_for_organization'))->toBeTrue(); + // A second identical check is served from the in-request memo, still correct. + expect($aauth->can('create_something_for_organization'))->toBeTrue(); }); -test('clearContext clears both request cache and context', function () { +test('clearContext resets the instance context and a check still resolves', function () { $user = User::find(1); $role = Role::whereName('Root Role 1')->first(); $aauth = new AAuth($user, $role->id); - - // Call can() to populate request cache $aauth->can('create_something_for_organization'); - // Clear context $aauth->clearContext(); - // Context should be null - $context = Context::getHidden('aauth_context'); - expect($context)->toBeNull(); + // The context is rebuilt on demand — the check still returns the correct result. + expect($aauth->can('create_something_for_organization'))->toBeTrue(); }); -test('context is reloaded after clearContext when can() is called', function () { +test('a permission granted mid-request is reflected after clearContext', function () { $user = User::find(1); $role = Role::whereName('Root Role 1')->first(); $aauth = new AAuth($user, $role->id); + expect($aauth->can('brand_new_perm'))->toBeFalse(); - // Clear context + RolePermission::create(['role_id' => $role->id, 'permission' => 'brand_new_perm']); $aauth->clearContext(); - // Call can() - should reload context - $result = $aauth->can('create_something_for_organization'); + expect($aauth->can('brand_new_perm'))->toBeTrue(); +}); + +test('two AAuth instances in one request never read each other\'s permissions (no leak)', function () { + // A fresh role WITH a unique permission, assigned to user 1. + $roleWith = Role::create(['name' => 'HasIsolatedPerm', 'status' => 'active']); + RolePermission::create(['role_id' => $roleWith->id, 'permission' => 'perm.isolated']); + DB::table('user_role_organization_node')->insert([ + 'user_id' => 1, 'role_id' => $roleWith->id, 'organization_node_id' => 1, + ]); - // Context should be restored - $context = Context::getHidden('aauth_context'); - expect($context)->not->toBeNull() - ->and($result)->toBeTrue(); + // A fresh role WITHOUT that permission, assigned to user 2. + $roleWithout = Role::create(['name' => 'NoIsolatedPerm', 'status' => 'active']); + DB::table('user_role_organization_node')->insert([ + 'user_id' => 2, 'role_id' => $roleWithout->id, 'organization_node_id' => 1, + ]); + + $aauthUser1 = new AAuth(User::find(1), $roleWith->id); + // Constructed AFTER user 1 — with a shared Context store this overwrote user 1's + // context and leaked; with per-instance context each sees only its own. + $aauthUser2 = new AAuth(User::find(2), $roleWithout->id); + + expect($aauthUser1->can('perm.isolated'))->toBeTrue(); // user 1 has it + expect($aauthUser2->can('perm.isolated'))->toBeFalse(); // user 2 must NOT see it + // Re-checking user 1 after user 2 was built still returns user 1's own result. + expect($aauthUser1->can('perm.isolated'))->toBeTrue(); }); /* @@ -230,44 +244,6 @@ |-------------------------------------------------------------------------- */ -test('role is cached when cache is enabled', function () { - config(['aauth-advanced.cache.enabled' => true]); - config(['aauth-advanced.cache.ttl' => 3600]); - config(['aauth-advanced.cache.prefix' => 'aauth_test']); - - $user = User::find(1); - $role = Role::whereName('Root Role 1')->first(); - - // Clear any existing cache - Cache::forget("aauth_test:role:{$role->id}"); - - $aauth = new AAuth($user, $role->id); - - // Role should be cached - $cachedRole = Cache::get("aauth_test:role:{$role->id}"); - expect($cachedRole)->not->toBeNull() - ->and($cachedRole->id)->toBe($role->id); -}); - -test('switchable roles are cached when cache is enabled', function () { - config(['aauth-advanced.cache.enabled' => true]); - config(['aauth-advanced.cache.ttl' => 3600]); - config(['aauth-advanced.cache.prefix' => 'aauth_test']); - - $user = User::find(1); - $role = Role::whereName('Root Role 1')->first(); - - // Clear any existing cache - Cache::forget("aauth_test:user:{$user->id}:switchable_roles"); - - $aauth = new AAuth($user, $role->id); - $aauth->switchableRoles(); - - // Switchable roles should be cached - $cachedRoles = Cache::get("aauth_test:user:{$user->id}:switchable_roles"); - expect($cachedRoles)->not->toBeNull(); -}); - /* |-------------------------------------------------------------------------- | Organization Depth Filtering Tests From 11e10fb550324f9175aa9915f3c5ff6c3a3e0917 Mon Sep 17 00:00:00 2001 From: Emre Akay Date: Fri, 3 Jul 2026 23:25:47 +0300 Subject: [PATCH 18/20] chore(agent): add LA13 per-request-load + SOLID checks to laravel-architect The pre-PR architect now also enforces: authorization data loaded once per request (no per-check DB query, loadMissing over load, no persistent cache unless it earns its keep), and SOLID in its lean reading (one responsibility, no speculative layers/second source of truth). North Star updated to name SOLID explicitly. Co-Authored-By: Claude Fable 5 --- .claude/agents/laravel-architect.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.claude/agents/laravel-architect.md b/.claude/agents/laravel-architect.md index 3e29b2c..7ebf942 100644 --- a/.claude/agents/laravel-architect.md +++ b/.claude/agents/laravel-architect.md @@ -11,7 +11,7 @@ tools: Read, Grep, Glob, Bash You are a **senior Laravel package architect** reviewing changes to **AAuth** (`aurorawebsoftware/aauth`) — a lean authorization package (RBAC + additive ABAC + materialized-path OrBAC) that is the **foundation of many downstream apps**. Your job: review the working diff BEFORE a PR and report architecture/leanness issues. You **recommend only — never edit**. ## North star -The maintainer wants this package **LEAN, SIMPLE, USABLE**. Your bias: fewer concepts, less config, smaller API, deleted code. **Flag over-engineering; praise simplification.** A "clever" or "flexible" abstraction that isn't needed is a defect here. +The maintainer wants this package **LEAN, SIMPLE, USABLE, and SOLID**. Your bias: fewer concepts, less config, smaller API, deleted code. **Flag over-engineering; praise simplification.** A "clever" or "flexible" abstraction that isn't needed is a defect here. Hold the code to **SOLID** — but the *lean* reading: single clear responsibility per class, depend on abstractions only where it removes real coupling, no speculative interfaces/layers. When SOLID and leanness seem to conflict, prefer the simpler design that a maintainer can read in one sitting. A persistent cache, an extra indirection layer, or a second source of truth that "might help someday" is over-engineering — call it out and propose deletion. ## Methodology 1. `git diff main...HEAD` (or `git diff` for unstaged) — scope the change. @@ -34,6 +34,7 @@ The maintainer wants this package **LEAN, SIMPLE, USABLE**. Your bias: fewer con | LA9 | Idiom | Idiomatic Laravel (Eloquent/relations/scopes/events used correctly; no reinventing framework features) → LOW | inspect | | LA10 | Config-less | No new hardcoded model/table string where the pattern should be reused; but do **not** introduce a config system just for this → LOW | grep string literals | | LA11 | **Cache security** | **Every cache key MUST encode the FULL security context it caches for** — user id **and** active role id **and** org-node/scope **and** permission+args. A key missing any dimension is a **DATA LEAK / privilege-escalation** vector, e.g.: (a) **cross-user bleed** — a key without the user id (`"switchable_roles"` instead of `"…:user:{id}"`) serves User A's roles/permissions/ABAC to User B; (b) **cross-role escalation** — a key without the active role serves a high-privilege role's cached `allow` to the same user on a lower role; (c) **stale allow** — an authz write (role/permission/ABAC/org-node/assignment) not matched by a cache bust keeps granting a **revoked** permission. Also: honours the **configured store** (never hardcodes `default`); no cache **tags** on tag-less stores (array/file/database throw); per-request `requestCache` keyed by the same full context and never shared across users. → **BLOCKER** on any cross-user/cross-role bleed or a stale *allow* | grep `Cache::`, `cache(`, `clearUserRoleCache`, `getPermissionCacheKey`, `remember`, observers; for EACH key list which context dims it includes and prove no other user/role can hit it | +| LA13 | **Per-request efficiency + SOLID** | Authorization data (role + permissions + ABAC) is loaded **ONCE per request** and served from the request-scoped instance — `can()`/permission checks must **NOT** re-query the DB on every call, and must **NOT** redundantly re-load already-eager-loaded relations (`loadMissing` over `load`). No persistent/global cache with its own invalidation apparatus unless it genuinely earns its keep (prefer per-request in-memory loading). SOLID (lean reading): one clear responsibility per class, no speculative interface/layer, no second source of truth → MEDIUM if `can()` hits the DB per call or a redundant re-load exists; HIGH if a heavy cache/abstraction is re-introduced where per-request loading suffices | grep `can(`, `getAuthContext`, `load(` vs `loadMissing(`, `remember(`, `Cache::`; trace how many queries one request's permission checks issue | | LA12 | **Octane** | Safe under long-lived workers (Swoole/RoadRunner): the `aauth` binding is **`scoped()` not `singleton()`**; **NO static/global mutable state** that survives a request (static props, static caches, `Gate::before` closures capturing per-user state); per-instance state (`requestCache`, `organizationNodeIds`, super-admin flag, current role) **resets per request** so User A cannot bleed into User B on the same worker; `Context::`/request-scoped storage used correctly; `Auth::user()`/`config()` not resolved-and-frozen at boot → **BLOCKER** if per-user state can leak across requests | grep `singleton(`, `scoped(`, `static `, `Context::`, `AAuthServiceProvider`, `switchableRolesStatic` | ## AAuth red-lines (BLOCK the PR) @@ -46,7 +47,7 @@ The maintainer wants this package **LEAN, SIMPLE, USABLE**. Your bias: fewer con ## Output format (emit exactly this) ``` ### 🏛️ [laravel-architect] verdict: APPROVE | CHANGES_REQUESTED | BLOCK -. Checklist: X/12 passed. +. Checklist: X/13 passed. | # | Severity | Category | Finding | Location | Fix | |---|----------|----------|---------|----------|-----| From 432b9a91a1b26a489b348653c3537b558d9155c4 Mon Sep 17 00:00:00 2001 From: Emre Akay Date: Fri, 3 Jul 2026 23:35:39 +0300 Subject: [PATCH 19/20] docs: remove references to the deleted cache subsystem MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The persistent cache was removed in the previous commit; drop the now-false docs that still advertised it (README Performance/Cache sections, UPGRADE cache config + 'Cache Issues' runbook, CHANGELOG fix clause, config header comment). Replace with a short note that authorization data is loaded once per request into the scoped instance — no cross-request cache. Flagged by the laravel-architect + data-integrity review agents (doc drift). Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 8 +++++--- README.md | 42 +++++---------------------------------- UPGRADE.md | 41 +++++++------------------------------- config/aauth-advanced.php | 2 +- 4 files changed, 18 insertions(+), 75 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3665dcb..ce71c76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,9 +15,11 @@ Secure-by-default fixes for confirmed authorization defects (no config flags). S `descendant()` is `/`-separator-anchored; ABAC rule attributes are allowlisted; an empty accessible-node set returns zero rows (fail closed). - **Fixes:** `Role::permissions()` (returned every role's permissions), assigned-user count, - non-atomic permission sync and organization-node create, the pgsql seed sequence, cache - invalidation now honours the configured store, and the runtime-fatal org-node update/delete - trait helpers. + non-atomic permission sync and organization-node create, the pgsql seed sequence, and the + runtime-fatal org-node update/delete trait helpers. +- **Removed:** the opt-in role/permission cache (`aauth-advanced.cache`). Authorization data + is now loaded once per request into the request-scoped instance — no persistent cache, no + invalidation apparatus, tenant-safe; a published `cache` config key is simply ignored. - **CI:** the suite runs on SQLite, MariaDB and PostgreSQL (GitHub Actions + GitLab CI); a `/pre-pr-review` agent suite lives in `.claude/`. diff --git a/README.md b/README.md index 9ca3f81..5ad8fb9 100644 --- a/README.md +++ b/README.md @@ -1239,32 +1239,11 @@ use AuroraWebSoftware\AAuth\Events\PermissionRemovedEvent; // Properties: Role $ ## Performance Optimization -AAuth v2 includes built-in caching and database optimization features. - -### Cache Configuration - -```php -// config/aauth.php -'cache' => [ - 'enabled' => true, // Enable/disable caching - 'ttl' => 3600, // Cache TTL in seconds (1 hour) - 'prefix' => 'aauth', // Cache key prefix - 'store' => null, // Cache store (null = default) -], -``` - -### What Gets Cached - -- **Role data**: Role with permissions and ABAC rules -- **Switchable roles**: User's available roles for switching - -### Cache Invalidation - -Cache is automatically invalidated when: - -- Role is updated or deleted (via `RoleObserver`) -- Permission is added, updated, or removed (via `RolePermissionObserver`) -- User role assignment changes (via `RolePermissionService`) +AAuth loads a user's role, permissions and ABAC rules **once per request** into the +request-scoped `AAuth` instance, so every `can()` / permission check within a request is +served from memory — no per-check database query. There is intentionally **no +cross-request cache**: it is unnecessary here and avoids stale-permission and +multi-tenant cache-key hazards. ### Database Indexes @@ -1277,17 +1256,6 @@ AAuth v2 includes optimized database indexes for better query performance: - idx_organization_nodes_path // Organization hierarchy queries ``` -### Disabling Cache - -To disable caching (e.g., for debugging): - -```php -// config/aauth.php -'cache' => [ - 'enabled' => false, -], -``` - --- ## Organization Depth Filtering diff --git a/UPGRADE.md b/UPGRADE.md index ec85fb9..87ffe7c 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -53,8 +53,13 @@ return **zero rows** (fail closed) for a role with no accessible nodes, instead whole table or an exception. Also fixed (non-behavioural): `Role::permissions()` (was returning every role's -permissions), the assigned-user count, non-atomic permission sync, the pgsql seed -sequence, and cache invalidation now honours the configured `cache.store`. +permissions), the assigned-user count, non-atomic permission sync, and the pgsql seed +sequence. + +**Removed the opt-in role/permission cache** (`aauth-advanced.cache.*`). Authorization +data is now loaded **once per request** into the request-scoped AAuth instance — no +persistent cache, no invalidation apparatus, tenant-safe, and no action needed. A +published `aauth-advanced.php` simply ignores the old `cache` key. ## Upgrading to 21.1.0 (security + reliability minor) @@ -153,12 +158,6 @@ return [ 'enabled' => false, 'column' => 'is_super_admin', ], - 'cache' => [ - 'enabled' => true, - 'ttl' => 3600, - 'prefix' => 'aauth', - 'store' => null, - ], ]; ``` @@ -201,18 +200,6 @@ Minimum name length changed from 5 to 3 characters: ## New Features in v2 -### Caching - -Role and permission data is now cached by default: - -```php -// Disable caching if needed -// config/aauth.php -'cache' => [ - 'enabled' => false, -], -``` - ### Organization Depth Filtering ```php @@ -262,20 +249,6 @@ DB::table('roles') ## Troubleshooting -### Cache Issues - -If you experience stale data after updates: - -```php -// Clear AAuth cache manually -$prefix = config('aauth.cache.prefix', 'aauth'); -Cache::forget("{$prefix}:role:{$roleId}"); -Cache::forget("{$prefix}:user:{$userId}:switchable_roles"); - -// Or disable cache temporarily -// config/aauth.php: 'cache' => ['enabled' => false] -``` - ### Database Index Errors If migration fails due to existing indexes: diff --git a/config/aauth-advanced.php b/config/aauth-advanced.php index 824c664..129778f 100644 --- a/config/aauth-advanced.php +++ b/config/aauth-advanced.php @@ -5,7 +5,7 @@ | AAuth Advanced Configuration (v2 Features) |-------------------------------------------------------------------------- | -| This config contains v2 features: caching, super admin, and other +| This config contains v2 features: super admin, and other | advanced settings. These features are optional and backward compatible. | | To enable these features, publish this config: From aad64fdbb34109d0dc710530a90fd0a77e9679e7 Mon Sep 17 00:00:00 2001 From: Emre Akay Date: Fri, 3 Jul 2026 23:50:59 +0300 Subject: [PATCH 20/20] docs: modernize README/UPGRADE, drop Laravel Boost guidelines, add AGENTS.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Doc-quality pass (findings from the laravel-architect doc audit, all verified vs code): - Rewrote the Organization (OrBAC) and ABAC concept sections with a tree diagram, the real rule format, and the secure-by-default 'no rule = sees all rows' guarantee. - Fixed stale/incorrect content: features list no longer advertises the removed cache; service-provider example shows scoped() (not singleton); withoutGlobalScopes()->get() (was ->all(), not runnable); UPGRADE support matrix (Laravel 11-13, PHP 8.2-8.4, SQLite/MySQL/MariaDB/Postgres); super_admin config path (aauth-advanced.php); two mislabeled attach/detach headers now match their code. - Documented previously-missing behaviour: the Blade directives (@aauth/@aauth_can/ @aauth_role/@aauth_super_admin) and the constructor 'when AAuth throws' prerequisites. - Removed Laravel Boost: deleted resources/boost/guidelines/core.blade.php and the misplaced .ai/guidelines/core.blade.php (both stale — still described the cache). Boost is app-level AI tooling and its package file drifted; replaced with a lean, tool-agnostic AGENTS.md (Claude Code / Cursor / Copilot) carrying the non-negotiable rules (zero-leak, secure-by-default, no-breaking, lean, Octane-safe) + the quality gates. No code change; phpstan clean, 153 tests green. Co-Authored-By: Claude Fable 5 --- .ai/guidelines/core.blade.php | 741 ---------------------- AGENTS.md | 62 ++ PROJECT_ANALYSIS.md | 2 +- README.md | 119 +++- UPGRADE.md | 8 +- resources/boost/guidelines/core.blade.php | 741 ---------------------- 6 files changed, 160 insertions(+), 1513 deletions(-) delete mode 100644 .ai/guidelines/core.blade.php create mode 100644 AGENTS.md delete mode 100644 resources/boost/guidelines/core.blade.php diff --git a/.ai/guidelines/core.blade.php b/.ai/guidelines/core.blade.php deleted file mode 100644 index 5ee89bb..0000000 --- a/.ai/guidelines/core.blade.php +++ /dev/null @@ -1,741 +0,0 @@ -## AAuth - Comprehensive Access Control for Laravel - -AAuth is a Laravel package that combines **Organization-Based Access Control (OrBAC)**, **Role-Based Access Control (RBAC)**, and **Attribute-Based Access Control (ABAC)** in a single solution. It provides limitless hierarchical organization levels, parametric permissions, and attribute-based filtering for fine-grained access control. - -### Core Features - -- **OrBAC**: Hierarchical organization tree with unlimited levels using Materialized Path Pattern -- **RBAC**: System and Organization roles with parametric permissions -- **ABAC**: Model-level attribute filtering with JSON rules applied via global scopes -- **Automatic Data Filtering**: Global scopes filter data based on user's authorized organization nodes -- **Middleware**: Route-level permission, role, and organization scope protection -- **Gate Integration**: `Gate::before` callback for Laravel authorization integration -- **Cache**: Configurable caching with automatic invalidation via observers -- **Super Admin**: Bypass all permission checks with configurable column -- **Events**: Lifecycle events for roles and permissions -- **Blade Directives**: `@aauth`, `@aauth_can`, `@aauth_role`, `@aauth_super_admin` -- **Multi-Database**: MySQL, MariaDB, and PostgreSQL compatible - -### Installation - -@verbatim - -composer require aurorawebsoftware/aauth -php artisan migrate -php artisan vendor:publish --tag="aauth-config" - -@endverbatim - -### Configuration Files - -AAuth uses three config files: - -- **`config/aauth.php`** - Permission definitions (system and organization permissions) -- **`config/aauth-advanced.php`** - Cache settings, super admin toggle -- **`config/aauth-permissions.php`** - Optional UI-focused permission definitions with parameters - -@verbatim - -// config/aauth.php -return [ - 'permissions' => [ - 'system' => [ - 'edit_something_for_system' => 'aauth/system.edit_something_for_system', - ], - 'organization' => [ - 'edit_something_for_organization' => 'aauth/organization.edit_something_for_organization', - ], - ], -]; - -@endverbatim - -@verbatim - -// config/aauth-advanced.php -return [ - 'super_admin' => [ - 'enabled' => false, - 'column' => 'is_super_admin', // column on users table - ], - 'cache' => [ - 'enabled' => true, - 'ttl' => 3600, - 'prefix' => 'aauth', - 'store' => null, // null = default cache driver - ], -]; - -@endverbatim - -@verbatim - -// config/aauth-permissions.php - Used for UI display only -return [ - 'posts' => [ - 'edit' => [ - 'key' => 'posts.edit', - 'description' => 'Edit posts', - 'parameters' => [ - 'max_edits_per_day' => [ - 'type' => 'integer', - 'default' => null, - 'description' => 'Maximum edits per day', - ], - 'allowed_statuses' => [ - 'type' => 'array', - 'default' => ['draft', 'published'], - 'description' => 'Which statuses can be edited', - ], - ], - ], - ], -]; - -@endverbatim - -### User Model Setup - -The User model must implement `AAuthUserContract` and use the `AAuthUser` trait: - -@verbatim - -use Illuminate\Foundation\Auth\User as Authenticatable; -use AuroraWebSoftware\AAuth\Traits\AAuthUser; -use AuroraWebSoftware\AAuth\Contracts\AAuthUserContract; - -class User extends Authenticatable implements AAuthUserContract -{ - use AAuthUser; -} - -@endverbatim - -The `AAuthUser` trait provides: `roles()`, `system_roles()`, `organization_roles()`, `rolesWithOrganizationNodes()`, `getAssignedUserCountAttribute()`, `getDeletableAttribute()`, and overrides `can()` to integrate with AAuth. - -The `can()` override supports both string and array abilities: - -@verbatim - -// Single permission check (uses AAuth) -$user->can('edit_something'); - -// Multiple permissions check (all must pass) -$user->can(['edit_something', 'view_something']); - -@endverbatim - -### Making Models Organization-Controllable (OrBAC) - -Implement `AAuthOrganizationNodeInterface` and use the `AAuthOrganizationNode` trait: - -@verbatim - -use AuroraWebSoftware\AAuth\Interfaces\AAuthOrganizationNodeInterface; -use AuroraWebSoftware\AAuth\Traits\AAuthOrganizationNode; -use Illuminate\Database\Eloquent\Model; - -class School extends Model implements AAuthOrganizationNodeInterface -{ - use AAuthOrganizationNode; - - public static function getModelType(): string - { - return self::class; - } - - public function getModelId(): int - { - return $this->id; - } - - public function getModelName(): ?string - { - return $this->name; - } -} - -@endverbatim - -Once a model uses this trait, all queries are **automatically filtered** by the user's authorized organization nodes. `School::all()` returns only schools the user has access to. - -### Making Models ABAC-Filterable - -Implement `AAuthABACModelInterface` and use the `AAuthABACModel` trait: - -@verbatim - -use AuroraWebSoftware\AAuth\Interfaces\AAuthABACModelInterface; -use AuroraWebSoftware\AAuth\Traits\AAuthABACModel; -use Illuminate\Database\Eloquent\Model; - -class Order extends Model implements AAuthABACModelInterface -{ - use AAuthABACModel; - - public static function getModelType(): string - { - return 'order'; - } - - public static function getABACRules(): array - { - return [ - '&&' => [ - ['=' => ['attribute' => 'status', 'value' => 'active']], - ], - ]; - } -} - -@endverbatim - -### Materialized Path Pattern - -AAuth stores the full hierarchy path in a single column for each organization node. This eliminates recursive queries and makes filtering extremely fast. - -**Path Format:** `parent_id/current_id` (e.g., `1/5/7`) - -@verbatim - -Organization Node ID: 1 (School District) Path: 1 -├── ID: 5 (School A) Path: 1/5 -│ ├── ID: 7 (Math Dept) Path: 1/5/7 -│ │ ├── ID: 9 (Student: John) Path: 1/5/7/9 -│ │ └── ID: 10 (Student: Jane) Path: 1/5/7/10 -│ └── ID: 8 (Science Dept) Path: 1/5/8 -│ └── ID: 11 (Student: Bob) Path: 1/5/8/11 -└── ID: 6 (School B) Path: 1/6 - -@endverbatim - -The `AAuthOrganizationNodeScope` global scope automatically adds WHERE clauses using LIKE pattern matching on the path column: - -@verbatim - -// Teacher assigned to Math Dept (node 7, path: 1/5/7) -$students = Student::all(); - -// AAuth transforms this to: -// SELECT students.* FROM students -// INNER JOIN organization_nodes ON organization_nodes.model_id = students.id -// WHERE organization_nodes.model_type = 'App\Models\Student' -// AND (organization_nodes.path LIKE '1/5/7/%' OR organization_nodes.path = '1/5/7') -// -// Returns only John and Jane (not Bob from Science Dept) - -@endverbatim - -### Multiple Organization Node Access - -Users can be assigned to **multiple organization nodes simultaneously**. AAuth combines all authorized paths with OR conditions: - -@verbatim - -// Assign teacher to BOTH Math and Science departments -$rolePermissionService->attachOrganizationRoleToUser(7, $teacherRole->id, $teacher->id); -$rolePermissionService->attachOrganizationRoleToUser(8, $teacherRole->id, $teacher->id); - -// Query automatically combines with OR: -// WHERE (path LIKE '1/5/7/%' OR path = '1/5/7' OR path LIKE '1/5/8/%' OR path = '1/5/8') -$students = Student::all(); // Returns students from BOTH departments - -@endverbatim - -### Creating Organization Structure - -@verbatim - -use AuroraWebSoftware\AAuth\Services\OrganizationService; - -$organizationService = new OrganizationService(); - -$scope = $organizationService->createOrganizationScope([ - 'name' => 'School System', - 'level' => 1, - 'status' => 'active', -]); - -$node = $organizationService->createOrganizationNode([ - 'name' => 'High School A', - 'organization_scope_id' => $scope->id, - 'parent_id' => null, // Root node -]); - -@endverbatim - -`OrganizationService` also provides: `updateOrganizationScope()`, `deleteOrganizationScope()`, `getPath()`, `updateNodePathsRecursively()`, `deleteOrganizationNodesRecursively()`. - -### Creating Roles and Permissions - -@verbatim - -use AuroraWebSoftware\AAuth\Services\RolePermissionService; - -$rolePermissionService = new RolePermissionService(); - -$role = $rolePermissionService->createRole([ - 'organization_scope_id' => $organizationScope->id, - 'type' => 'system', // or 'organization' - 'name' => 'System Administrator', - 'status' => 'active', -]); - -// Attach single permission -$rolePermissionService->attachPermissionToRole('edit_something', $role->id); - -// Attach multiple permissions -$rolePermissionService->attachPermissionToRole([ - 'create_something', 'edit_something', 'delete_something', -], $role->id); - -// Sync permissions (replaces all existing) -$rolePermissionService->syncPermissionsOfRole(['create_something', 'edit_something'], $role->id); - -// Detach permissions -$rolePermissionService->detachPermissionFromRole('edit_something', $role->id); -$rolePermissionService->detachAllPermissionsFromRole($role->id); - -@endverbatim - -### Assigning Roles to Users - -@verbatim - -// System roles (organization-independent) -$rolePermissionService->attachSystemRoleToUser($role->id, $user->id); -$rolePermissionService->attachSystemRoleToUser([$role1->id, $role2->id], $user->id); -$rolePermissionService->syncUserSystemRoles($user->id, [$role1->id, $role2->id]); -$rolePermissionService->detachSystemRoleFromUser($role->id, $user->id); - -// Organization roles (require an organization node) -$rolePermissionService->attachOrganizationRoleToUser( - $organizationNode->id, $role->id, $user->id -); -$rolePermissionService->detachOrganizationRoleFromUser($user->id, $role->id, $organizationNode->id); - -@endverbatim - -### Session-Based Role Selection - -AAuth uses session to track the current active role. The `roleId` must be set in session before using AAuth: - -@verbatim - -use Illuminate\Support\Facades\Session; - -Session::put('roleId', $role->id); - -@endverbatim - -### Using AAuth Facade - Permission Checks - -@verbatim - -use AuroraWebSoftware\AAuth\Facades\AAuth; - -// Check permission -if (AAuth::can('edit_something')) { /* ... */ } - -// Check with parametric arguments -if (AAuth::can('posts.edit', 5, 'draft')) { /* ... */ } - -// Abort if no permission (returns 401) -AAuth::passOrAbort('edit_something', 'You do not have permission'); - -// Get all permissions for current role -$permissions = AAuth::permissions(); -$orgPermissions = AAuth::organizationPermissions(); -$sysPermissions = AAuth::systemPermissions(); - -// Check super admin -if (AAuth::isSuperAdmin()) { /* bypass all checks */ } - -// Get current role -$role = AAuth::currentRole(); - -// Get switchable roles for current user -$roles = AAuth::switchableRoles(); -$roles = AAuth::switchableRolesStatic($userId); - -@endverbatim - -### Using AAuth Facade - Organization Nodes - -@verbatim - -// Get all authorized organization nodes -$nodes = AAuth::organizationNodes(); -$nodes = AAuth::organizationNodes(includeRootNode: true); -$nodes = AAuth::organizationNodes(modelType: School::class); - -// Get query builder for custom queries -$query = AAuth::organizationNodesQuery(includeRootNode: true, modelType: School::class); - -// Get specific authorized node (throws InvalidOrganizationNodeException if not authorized) -$node = AAuth::organizationNode(nodeId: 5); - -// Get array of authorized node IDs -$ids = AAuth::organizationNodeIds(); - -// Check descendant relationship -$isDescendant = AAuth::descendant(rootNodeId: 1, childNodeId: 5); - -// Advanced: filter by depth and scope -$nodes = AAuth::getAccessibleOrganizationNodes( - minDepthFromRoot: 1, - maxDepthFromRoot: 3, - scopeName: 'Department', - scopeLevel: 2, - includeRootNode: true, - modelType: School::class, -); - -@endverbatim - -### Using AAuth Facade - ABAC Rules - -@verbatim - -use AuroraWebSoftware\AAuth\Models\RoleModelAbacRule; - -// Get ABAC rules for a model type -$rules = AAuth::ABACRules('order'); - -// Create ABAC rule linking a role to a model type -RoleModelAbacRule::create([ - 'role_id' => $role->id, - 'model_type' => Order::getModelType(), - 'rules_json' => [ - '&&' => [ - ['=' => ['attribute' => 'status', 'value' => 'approved']], - ['>=' => ['attribute' => 'amount', 'value' => 100]], - ['||' => [ - ['=' => ['attribute' => 'category', 'value' => 'electronics']], - ['=' => ['attribute' => 'category', 'value' => 'books']], - ]], - ], - ], -]); - -// ABAC operators: =, !=, >, <, >=, <=, like -// Logical operators: && (AND), || (OR) - can be nested - -@endverbatim - -### Role Model Methods - -The Role model provides direct methods for permission management: - -@verbatim - -// Give a permission (with optional parametric arguments) -$role->givePermission('edit_posts'); -$role->givePermission('edit_posts', ['max_edits_per_day' => 10, 'allowed_statuses' => ['draft']]); - -// Remove a permission -$role->removePermission('edit_posts'); - -// Sync permissions (replaces all existing) -$role->syncPermissions(['view_posts', 'edit_posts', 'delete_posts']); - -// Sync with parameters -$role->syncPermissions([ - 'view_posts' => null, - 'edit_posts' => ['max_edits_per_day' => 5], -]); - -// Check if role has a permission -if ($role->hasPermission('edit_posts')) { /* ... */ } - -// Relationships -$role->rolePermissions; // HasMany -> RolePermission (with parameters) -$role->abacRules; // HasMany -> RoleModelAbacRule -$role->organization_scope; // BelongsTo -> OrganizationScope -$role->organization_nodes; // BelongsToMany -> OrganizationNode - -// Computed attributes -$role->assigned_user_count; // Number of users assigned to this role -$role->deletable; // true if no users assigned - -@endverbatim - -### Helper Functions - -AAuth provides global helper functions (autoloaded via `src/helpers.php`): - -@verbatim - -// Get AAuth service instance -$aauth = aauth(); - -// Check permission -if (aauth_can('edit_something', $param1, $param2)) { /* ... */ } - -// Check current role name -if (aauth_has_role('System Administrator')) { /* ... */ } - -// Get current active role -$role = aauth_active_role(); // returns ?Role - -// Get first active organization node -$node = aauth_active_organization(); // returns ?OrganizationNode - -// Check super admin status -if (aauth_is_super_admin()) { /* ... */ } - -@endverbatim - -### Creating Models with Organization Nodes - -@verbatim - -// Create model and its organization node together -$school = School::createWithAAuthOrganizationNode( - ['name' => 'New School', 'address' => '123 Main St'], - $parentOrganizationNodeId, - $organizationScopeId -); - -// Update model and sync organization node (static method) -School::updateWithAAuthOrganizationNode( - $modelId, - $nodeId, - ['name' => 'Updated School'], - $parentOrganizationNodeId, - $organizationScopeId -); - -// Delete model and its organization node recursively (static method) -School::deleteWithAAuthOrganizationNode($modelId); - -// Access the related organization node -$orgNode = $school->relatedAAuthOrganizationNode(); - -// Get all records without organization scope filtering (instance method) -$all = (new School)->allWithoutAAuthOrganizationNodeScope(); - -@endverbatim - -### Middleware - -AAuth provides three middleware for route protection: - -@verbatim - -// Permission middleware - checks if user has the given permission -Route::get('/students', [StudentController::class, 'index']) - ->middleware('aauth.permission:view_students'); - -// Permission with parametric arguments -Route::put('/posts/{post}', [PostController::class, 'update']) - ->middleware('aauth.permission:posts.edit,5,draft'); - -// Role middleware - checks if current active role matches -Route::get('/admin', [AdminController::class, 'index']) - ->middleware('aauth.role:System Administrator'); - -// Organization scope middleware - checks if user's role belongs to given scope -Route::get('/department', [DeptController::class, 'index']) - ->middleware('aauth.organization:2'); // organization_scope_id = 2 - -@endverbatim - -### Blade Directives - -@verbatim - -{{-- Permission check --}} -@@aauth('edit_students') - -@@endaauth - -{{-- Permission check with @if-style syntax --}} -@@aauth_can('create_students') - -@@endaauth_can - -{{-- Role check --}} -@@aauth_role('System Administrator') - Admin Panel -@@endaauth_role - -{{-- Super admin check --}} -@@aauth_super_admin - Super Admin Tools -@@endaauth_super_admin - -@endverbatim - -### Laravel Gate Integration - -AAuth integrates with Laravel's built-in authorization via a `Gate::before` callback registered in the service provider: - -@verbatim - -// Registered in AAuthServiceProvider::boot() -Gate::before(function ($user, $ability, $arguments = []) { - $aauth = app('aauth'); - - // Super admin bypasses all permission checks - if ($aauth->isSuperAdmin()) { - return true; - } - - // Delegate to AAuth::can() for all Gate checks - return $aauth->can($ability, ...$arguments) ?: null; -}); - -// This means standard Laravel Gate/Policy checks work with AAuth: -Gate::allows('edit_something'); -$user->can('edit_something'); -@can('edit_something') ... @endcan - -@endverbatim - -### Events - -AAuth dispatches events for role and permission lifecycle: - -@verbatim - -use AuroraWebSoftware\AAuth\Events\RoleCreatedEvent; // Role $role -use AuroraWebSoftware\AAuth\Events\RoleUpdatedEvent; // Role $role -use AuroraWebSoftware\AAuth\Events\RoleDeletedEvent; // Role $role -use AuroraWebSoftware\AAuth\Events\RoleAssignedEvent; // int $userId, Role $role, ?OrganizationNode $organizationNode -use AuroraWebSoftware\AAuth\Events\RoleRemovedEvent; // int $userId, Role $role, ?OrganizationNode $organizationNode -use AuroraWebSoftware\AAuth\Events\RoleSwitchedEvent; // int $userId, Role $newRole, ?Role $oldRole, ?OrganizationNode $organizationNode -use AuroraWebSoftware\AAuth\Events\PermissionAddedEvent; // Role $role, string $permission, ?array $parameters -use AuroraWebSoftware\AAuth\Events\PermissionUpdatedEvent; // Role $role, string $permission, ?array $parameters, ?array $oldParameters -use AuroraWebSoftware\AAuth\Events\PermissionRemovedEvent; // Role $role, string $permission - -// Listen to events in your EventServiceProvider or listener classes - -@endverbatim - -### Caching - -AAuth uses two levels of caching: - -1. **Request-level**: Laravel `Context` and in-memory `requestCache` for per-request deduplication -2. **Cross-request**: Configurable cache (via `aauth-advanced.cache`) for role data and switchable roles - -Cache is **automatically invalidated** by `RoleObserver` and `RolePermissionObserver` when roles or permissions change. You can also manually clear: - -@verbatim - -// Clear request-level context cache -app('aauth')->clearContext(); - -@endverbatim - -### Bypassing Organization Filtering - -@verbatim - -use AuroraWebSoftware\AAuth\Scopes\AAuthOrganizationNodeScope; - -// Remove only organization scope -$allSchools = School::withoutGlobalScope(AAuthOrganizationNodeScope::class)->get(); - -// Remove all global scopes (including ABAC) -$allSchools = School::withoutGlobalScopes()->get(); - -@endverbatim - -### Database Tables - -AAuth creates these tables: - -- **`roles`** - id, organization_scope_id, type (system/organization), name, status -- **`role_permission`** - id, role_id, permission, parameters (JSON) -- **`user_role_organization_node`** - id, user_id, role_id, organization_node_id -- **`organization_scopes`** - id, name, level, status -- **`organization_nodes`** - id, organization_scope_id, name, model_type, model_id, path, parent_id -- **`role_model_abac_rules`** - id, role_id, model_type, rules_json (JSON) - -### Key Enums - -@verbatim - -use AuroraWebSoftware\AAuth\Enums\RoleType; // system, organization -use AuroraWebSoftware\AAuth\Enums\ActivityStatus; // active, passive -use AuroraWebSoftware\AAuth\Enums\ABACCondition; // =, !=, >, <, >=, <=, like -use AuroraWebSoftware\AAuth\Enums\ABACLogicalOperator; // && (and), || (or) - -@endverbatim - -### Exceptions - -@verbatim - -use AuroraWebSoftware\AAuth\Exceptions\AuthorizationException; -use AuroraWebSoftware\AAuth\Exceptions\InvalidOrganizationNodeException; -use AuroraWebSoftware\AAuth\Exceptions\InvalidOrganizationScopeException; -use AuroraWebSoftware\AAuth\Exceptions\InvalidRoleException; -use AuroraWebSoftware\AAuth\Exceptions\InvalidRoleTypeException; -use AuroraWebSoftware\AAuth\Exceptions\InvalidUserException; -use AuroraWebSoftware\AAuth\Exceptions\MissingRoleException; -use AuroraWebSoftware\AAuth\Exceptions\OrganizationNodeAuthException; -use AuroraWebSoftware\AAuth\Exceptions\OrganizationScopesMismatchException; -use AuroraWebSoftware\AAuth\Exceptions\UserHasNoAssignedRoleException; - -@endverbatim - -### Internationalization (i18n) - -AAuth supports multiple languages for exception messages. Language files are at `resources/lang/{locale}/aauth.php`. - -@verbatim - -// Available languages: en, tr -// Publish language files: -// php artisan vendor:publish --tag="aauth-lang" - -// Exception message keys: -// aauth.authorization, aauth.invalid_location_scopes, aauth.invalid_organization_node, -// aauth.invalid_organization_scope, aauth.invalid_role, aauth.invalid_role_type, -// aauth.invalid_user, aauth.missing_role, aauth.organization_node_auth, -// aauth.organization_scopes_mismatch, aauth.user_has_no_assigned_role - -@endverbatim - -### Observers - -AAuth registers two observers that handle cache invalidation and event dispatching: - -- **RoleObserver**: On `created` fires `RoleCreatedEvent`, on `updated` clears role cache + fires `RoleUpdatedEvent`, on `deleted` clears role cache + clears affected users' switchable roles cache + fires `RoleDeletedEvent` -- **RolePermissionObserver**: On `created/updated/deleted` clears AAuth context (request cache) + clears role cache + fires `PermissionAddedEvent`/`PermissionUpdatedEvent`/`PermissionRemovedEvent` - -### Validation Request Classes - -AAuth provides FormRequest classes for validation: - -- `StoreRoleRequest` / `UpdateRoleRequest` - name: required, min:3 -- `StoreOrganizationNodeRequest` / `UpdateOrganizationNodeRequest` - name: required, min:3; parent_id: required, int -- `StoreOrganizationScopeRequest` / `UpdateOrganizationScopeRequest` - name: required, min:3; level: optional - -### Best Practices - -1. **Always set `roleId` in session** before using AAuth facade or any model with AAuth traits -2. **Use organization roles** for data belonging to organizational hierarchy -3. **Use system roles** for global permissions independent of organization -4. **Use middleware** for route-level protection instead of manual checks in controllers -5. **Use `AAuth::passOrAbort()`** in controllers/services for programmatic permission checks -6. **Use `withoutGlobalScope()`** when bypassing organization or ABAC filtering for admin operations -7. **Use transactions** when creating models with organization nodes for data consistency -8. **Use parametric permissions** for fine-grained control (e.g., max edits, allowed statuses) -9. **Listen to events** for audit logging and side effects on role/permission changes -10. **Enable cache** in production via `aauth-advanced.cache.enabled` for better performance - -### Important Notes - -- System permissions can only be assigned to system roles -- Organization permissions can only be assigned to organization roles -- Models using `AAuthOrganizationNode` trait automatically filter data via global scope -- Models using `AAuthABACModel` trait automatically filter data via ABAC global scope -- Organization nodes use path-based hierarchy (e.g., `1/5/7`) -- Users can have multiple roles, but only one active role per session -- The `Gate::before` callback integrates AAuth with Laravel's built-in authorization -- Cache is automatically invalidated when roles or permissions change -- Super admin (when enabled) bypasses all permission checks diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d915b33 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,62 @@ +# AGENTS.md — working with AAuth + +Tool-agnostic guidance for AI coding agents (Claude Code, Cursor, Copilot, …) working +**on** or **with** the `aurorawebsoftware/aauth` package. Humans: see `README.md` (usage) +and `README-contr.md` (contributing). + +## What AAuth is + +A Laravel authorization package combining three models in one: + +- **RBAC** — system & organization roles, permissions, and **parametric permissions** + (`can('approve', $amount)`). +- **OrBAC** — a **materialized-path** organization tree; a role acts on its node **and its + whole subtree**, enforced automatically on every query. +- **ABAC** — **row-level** filtering by a model's own attributes, applied as a global + scope. + +## Non-negotiable rules (this package is the security foundation of downstream apps) + +1. **Zero data leak.** No change may let a role read or write rows outside its RBAC + permissions, OrBAC subtree, or ABAC rules. When in doubt, add a **negative** test that + proves the out-of-scope path is denied. +2. **Secure-by-default, no flags.** Fixes make the correct behaviour the default; never add + a config flag to gate a security decision. +3. **ABAC is additive.** A role with **no** ABAC rule for a model sees **all** rows of it — + ABAC only ever restricts a role that has a rule. Do not change this. +4. **No breaking changes** within a major: no removed/re-typed public method, no config a + consumer reads silently removed without disclosure, no migration that fails on or + destroys existing data. Disclose behaviour changes in `UPGRADE.md` + `CHANGELOG.md`. +5. **Lean & SOLID.** Fewer concepts, less config, smaller API, deleted code. There is **no + cross-request cache** — authorization data loads **once per request** into the + request-scoped `AAuth` instance; every `can()` reads it from memory. Don't reintroduce a + persistent cache. +6. **Octane/Vapor-safe.** The `aauth` binding is `scoped()` (never `singleton()`); per-user + state lives on that scoped instance, never in shared/global storage. + +## How to work + +Quality gates (must stay green — CI runs all three on SQLite, MySQL/MariaDB, PostgreSQL): + +```bash +composer format # PHP-CS-Fixer (this repo uses php-cs-fixer, NOT Pint) +vendor/bin/phpstan analyse --memory-limit=1G # Larastan level 7 +AAUTH_TEST_DB=sqlite vendor/bin/pest # tests (or composer test:mariadb / test:pgsql via docker-compose) +``` + +- **Never add a PHPStan suppression** (`@phpstan-ignore`, `excludePaths`, a broad + `ignoreErrors`, or a new baseline entry) to pass — fix the cause. +- Every behaviour change ships with a test; security fixes ship with a **negative** test. +- Before opening a PR, run the specialist review agents in `.claude/agents/` via + `/pre-pr-review` (architect · security-pentest · test-quality · data-integrity · + db-engine-specialist). + +## Map + +- `src/AAuth.php` — the core service (RBAC checks, org-node queries, per-request context). +- `src/Scopes/` — the ABAC and OrBAC global scopes (security-critical; fail closed). +- `src/Traits/` — `AAuthUser`, `AAuthOrganizationNode`, `AAuthABACModel` (attach to host models). +- `src/Services/` — `RolePermissionService`, `OrganizationService`. +- `config/aauth.php` — permission registry; `config/aauth-advanced.php` — super-admin. + +More: `README.md`, `UPGRADE.md`, `README-contr.md`, `.claude/agents/README.md`. diff --git a/PROJECT_ANALYSIS.md b/PROJECT_ANALYSIS.md index c7e653e..233a133 100644 --- a/PROJECT_ANALYSIS.md +++ b/PROJECT_ANALYSIS.md @@ -22,7 +22,7 @@ 4. **Dokümantasyon** - README dosyaları mevcut - - Laravel Boost guideline'ları eklendi + - AGENTS.md (tool-agnostic AI rehberi) + README-contr + .claude/agents/ - Kod içi yorumlar var ### ⚠️ Eksiklikler ve İyileştirme Alanları diff --git a/README.md b/README.md index 5ad8fb9..d05eda9 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Organization Based (OrBAC) , Attibute Based (ABAC) , Rol-Permission (RBAC) Base - DB Row Level Filtering for the Role with ABAC - Built-in Blade Directives for permission control inside **Blade** files - Mysql, MariaDB, Postgres Support -- Built-in Caching Layer with Configurable TTL +- Per-request in-memory authorization loading — no cross-request cache; tenant-safe - **Laravel Octane / Vapor safe** — `aauth` is a request-scoped binding; no cross-request state leak - Community Driven and Open Source Forever @@ -191,26 +191,38 @@ This model-attribute-focused ABAC complements the Organization-Based Access Cont Before using AAuth its worth to understand the main terminology of AAuth. AAuth differs from other Auth Packages due to its organizational structure. -## What is Organization? +## What is Organization? (OrBAC) -Organization is a kind of term -which refers to hierarchical arrangement of eloquent models in sequential tree. +**Organization** models your org chart as a **tree** and scopes every authorization +decision to a user's position in it. A role placed on a node can act on that node **and +its entire subtree** — nothing above or beside it — and AAuth enforces this automatically +on every query. -It consists of a central root organization node, and sub organization nodes, -which are connected via edges. -We can also say that organization tree has one root node, many sub organization nodes polymorphic-connected with one -eloquent model. +```text +Acme Inc. Organization Node (root) · scope "Company" (level 0) · path "1" +├── Sales Dept Organization Node · scope "Department" (level 1) · path "1/2" +│ └── EU Team Organization Node · scope "Team" (level 2) · path "1/2/5" +└── Eng Dept Organization Node · scope "Department" (level 1) · path "1/3" +``` + +A user with an organization role on **Sales Dept** sees Sales Dept **and** EU Team, but +not Eng Dept. Each node stores its ancestry as a **materialized path** (`1/2/5`), so an +entire subtree is fetched in one indexed query — no recursion. ## Organization Scope -In Organization Tree, each node has an organization scope. -Organization scope has a level property to determine the level of the organization node in the tree. +An **Organization Scope** is a *type of level* in the tree — e.g. `Company`, `Department`, +`Team`. Each scope has a numeric `level` (root type = 0, then 1, 2, …). Every node belongs +to exactly one scope, letting you reason about "all Departments" or "every node at level 2" +across branches. ## Organization Node -Each node in the organization tree means organization node. -Each Organization Node is an Eloquent Model. -Organization Node can be polymorphic-related with an Eloquent Model. +An **Organization Node** is a concrete position in the tree (e.g. "Sales Dept"). It is an +Eloquent model (`OrganizationNode`) and can be **polymorphically linked to one of your own +models** — so "Sales Dept" can *be* your `Department` row. Add the `AAuthOrganizationNode` +trait to a model and its rows live in the tree and become subtree-scoped automatically: +`Department::all()` then returns only the departments inside the current role's subtree. ## Permission @@ -228,13 +240,36 @@ file's permission['system'] array. an Organization Role. Organization Permissions should be added inside `aauth.php` config file's permission['organization'] array. -## ABAC -Attribute-Based Access Control. AAuth provides comprehensive ABAC features, allowing for fine-grained access control based on model attributes. For a detailed explanation, see the sections: -- "Main Philosophy of AAuth ABAC" -- "Using ABAC Interface and Trait with Eloquent Models" -- "Defining ABAC Rules" -- "Managing ABAC Rules and Associations" -- "Automatic Query Filtering (ABAC)" +## ABAC (row-level filtering) + +**Attribute-Based Access Control** adds **row-level** filtering on top of roles: a role +can carry rules that decide *which rows* of a model it may read, from that model's own +attribute values — applied automatically as a global query scope, so you never write the +`where`s by hand. + +```php +// Give the "Regional Manager" role a rule on the Order model: +// status == 'approved' AND amount >= 100 +RoleModelAbacRule::updateOrCreate( + ['role_id' => $role->id, 'model_type' => Order::getModelType()], + ['rules_json' => ['&&' => [ + ['=' => ['attribute' => 'status', 'value' => 'approved']], + ['>=' => ['attribute' => 'amount', 'value' => 100]], + ]]], +); + +// Now, for any user on that role — filtered automatically, no manual where(): +Order::all(); // → only approved orders with amount >= 100 +Order::find($id); // → null unless that order matches the rule +``` + +**Secure-by-default rule:** ABAC only ever *restricts* a role that **has** a rule for a +model. **A role with no ABAC rule for a model sees every row of it** — ABAC never +accidentally hides data a role would otherwise have. Enable it by adding the +`AAuthABACModel` trait to your model. Operators (`&&`, `||`, `=`, `>=`, `in`, …) and the +full API are in ["Defining ABAC Rules"](#defining-abac-rules), +["Managing ABAC Rules"](#managing-abac-rules-and-associations) and +["Automatic Query Filtering"](#automatic-query-filtering-abac). ## Role @@ -506,7 +541,7 @@ roleId session must be set before initializing **AAuth** Service. `AAuthServiceProvider.php` ```php -$this->app->singleton('aauth', function ($app) { +$this->app->scoped('aauth', function ($app) { return new AAuth( Auth::user(), Session::get('roleId') @@ -639,7 +674,7 @@ $rolePermissionService->activateRole($roleId); $rolePermissionService->deactivateRole($roleId); ``` -### Attaching a Role to a User +### Attaching a Permission to a Role ```php $role = Role::whereName('System Role 1')->first(); $permissionName = 'test_permission1'; @@ -660,9 +695,9 @@ $rolePermissionService->syncPermissionsOfRole( ); ``` -### Detaching Permission from a Role +### Detaching a Permission from a Role ```php -$rolePermissionService->detachSystemRoleFromUser($role->id, $user->id); +$rolePermissionService->detachPermissionFromRole($permissionName, $role->id); ``` ### Creating an Organization Role and Attaching to a User @@ -791,6 +826,15 @@ This setup prepares your model to have ABAC rules applied to it. The `getModelTy ## AAuth Service and Facade Methods +> **Prerequisites — when AAuth throws.** Resolving `aauth` (or using the `AAuth` facade) +> builds the context for the **current authenticated user + their selected role**, so it +> needs an authenticated request. It throws `AuthenticationException` when there is no +> logged-in user, `MissingRoleException` when no `roleId` is in the session, and +> `UserHasNoAssignedRoleException` when the selected role is not assigned to the user or is +> `passive`. Do **not** call `AAuth::can()` where there is no authenticated user + active +> selected role (e.g. a queue job or console command) — handle authorization there +> explicitly instead. + ### Current Roles All Permissions current user's selected roles permissions with **AAuth Facade** ```php @@ -1046,11 +1090,11 @@ $allOrdersIncludingNonCompleted = Order::withoutGlobalScope(AAuthABACModelScope: // Find a specific order by ID, ignoring ABAC rules $anyOrder = Order::withoutGlobalScope(AAuthABACModelScope::class)->find(1); ``` -The `withoutGlobalScopes()->all()` method mentioned earlier in the "Getting All Model Collection without any access control" section also effectively bypasses this and all other global scopes. Use this capability judiciously, as it circumvents the defined access controls. +The `withoutGlobalScopes()->get()` method mentioned earlier in the "Getting All Model Collection without any access control" section also effectively bypasses this and all other global scopes. Use this capability judiciously, as it circumvents the defined access controls. ## Getting All Model Collection without any access control ```php -ExampleModel::withoutGlobalScopes()->all() +ExampleModel::withoutGlobalScopes()->get() ``` that's all. @@ -1174,6 +1218,29 @@ $user->can('edit_something'); --- +## Blade Directives + +AAuth also registers its own Blade directives for view-level checks (alongside Laravel's `@can`): + +```blade +{{-- Show a block only when the current role has the permission --}} +@aauth('edit_invoice') + Edit +@endaauth + +{{-- Conditionals (each supports @else) --}} +@aauth_can('approve', $amount) Approve @else Read only @endaauth_can +@aauth_role('Accountant') ... @endaauth_role +@aauth_super_admin Admin panel @endaauth_super_admin +``` + +- `@aauth('perm') … @endaauth` — permission check (directive pair). +- `@aauth_can('perm', ...$args)` — permission check with parametric arguments. +- `@aauth_role('RoleName')` — current-role name check. +- `@aauth_super_admin` — true when the user is a super admin. + +--- + ## Super Admin When enabled, users with the configured column set to `true` bypass all permission checks. diff --git a/UPGRADE.md b/UPGRADE.md index 87ffe7c..91ec064 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -121,9 +121,9 @@ This guide covers upgrading from AAuth v1 to v2. ## Requirements -- PHP 8.2 or higher -- Laravel 11 or 12 -- MySQL 8.0+ or PostgreSQL 13+ +- PHP 8.2, 8.3 or 8.4 +- Laravel 11, 12 or 13 +- SQLite, MySQL 8.0+ / MariaDB, or PostgreSQL 13+ ## Step 1: Update Composer @@ -152,7 +152,7 @@ php artisan vendor:publish --tag="aauth-config" --force New config options in v2: ```php -// config/aauth.php +// config/aauth-advanced.php return [ 'super_admin' => [ 'enabled' => false, diff --git a/resources/boost/guidelines/core.blade.php b/resources/boost/guidelines/core.blade.php deleted file mode 100644 index 5ee89bb..0000000 --- a/resources/boost/guidelines/core.blade.php +++ /dev/null @@ -1,741 +0,0 @@ -## AAuth - Comprehensive Access Control for Laravel - -AAuth is a Laravel package that combines **Organization-Based Access Control (OrBAC)**, **Role-Based Access Control (RBAC)**, and **Attribute-Based Access Control (ABAC)** in a single solution. It provides limitless hierarchical organization levels, parametric permissions, and attribute-based filtering for fine-grained access control. - -### Core Features - -- **OrBAC**: Hierarchical organization tree with unlimited levels using Materialized Path Pattern -- **RBAC**: System and Organization roles with parametric permissions -- **ABAC**: Model-level attribute filtering with JSON rules applied via global scopes -- **Automatic Data Filtering**: Global scopes filter data based on user's authorized organization nodes -- **Middleware**: Route-level permission, role, and organization scope protection -- **Gate Integration**: `Gate::before` callback for Laravel authorization integration -- **Cache**: Configurable caching with automatic invalidation via observers -- **Super Admin**: Bypass all permission checks with configurable column -- **Events**: Lifecycle events for roles and permissions -- **Blade Directives**: `@aauth`, `@aauth_can`, `@aauth_role`, `@aauth_super_admin` -- **Multi-Database**: MySQL, MariaDB, and PostgreSQL compatible - -### Installation - -@verbatim - -composer require aurorawebsoftware/aauth -php artisan migrate -php artisan vendor:publish --tag="aauth-config" - -@endverbatim - -### Configuration Files - -AAuth uses three config files: - -- **`config/aauth.php`** - Permission definitions (system and organization permissions) -- **`config/aauth-advanced.php`** - Cache settings, super admin toggle -- **`config/aauth-permissions.php`** - Optional UI-focused permission definitions with parameters - -@verbatim - -// config/aauth.php -return [ - 'permissions' => [ - 'system' => [ - 'edit_something_for_system' => 'aauth/system.edit_something_for_system', - ], - 'organization' => [ - 'edit_something_for_organization' => 'aauth/organization.edit_something_for_organization', - ], - ], -]; - -@endverbatim - -@verbatim - -// config/aauth-advanced.php -return [ - 'super_admin' => [ - 'enabled' => false, - 'column' => 'is_super_admin', // column on users table - ], - 'cache' => [ - 'enabled' => true, - 'ttl' => 3600, - 'prefix' => 'aauth', - 'store' => null, // null = default cache driver - ], -]; - -@endverbatim - -@verbatim - -// config/aauth-permissions.php - Used for UI display only -return [ - 'posts' => [ - 'edit' => [ - 'key' => 'posts.edit', - 'description' => 'Edit posts', - 'parameters' => [ - 'max_edits_per_day' => [ - 'type' => 'integer', - 'default' => null, - 'description' => 'Maximum edits per day', - ], - 'allowed_statuses' => [ - 'type' => 'array', - 'default' => ['draft', 'published'], - 'description' => 'Which statuses can be edited', - ], - ], - ], - ], -]; - -@endverbatim - -### User Model Setup - -The User model must implement `AAuthUserContract` and use the `AAuthUser` trait: - -@verbatim - -use Illuminate\Foundation\Auth\User as Authenticatable; -use AuroraWebSoftware\AAuth\Traits\AAuthUser; -use AuroraWebSoftware\AAuth\Contracts\AAuthUserContract; - -class User extends Authenticatable implements AAuthUserContract -{ - use AAuthUser; -} - -@endverbatim - -The `AAuthUser` trait provides: `roles()`, `system_roles()`, `organization_roles()`, `rolesWithOrganizationNodes()`, `getAssignedUserCountAttribute()`, `getDeletableAttribute()`, and overrides `can()` to integrate with AAuth. - -The `can()` override supports both string and array abilities: - -@verbatim - -// Single permission check (uses AAuth) -$user->can('edit_something'); - -// Multiple permissions check (all must pass) -$user->can(['edit_something', 'view_something']); - -@endverbatim - -### Making Models Organization-Controllable (OrBAC) - -Implement `AAuthOrganizationNodeInterface` and use the `AAuthOrganizationNode` trait: - -@verbatim - -use AuroraWebSoftware\AAuth\Interfaces\AAuthOrganizationNodeInterface; -use AuroraWebSoftware\AAuth\Traits\AAuthOrganizationNode; -use Illuminate\Database\Eloquent\Model; - -class School extends Model implements AAuthOrganizationNodeInterface -{ - use AAuthOrganizationNode; - - public static function getModelType(): string - { - return self::class; - } - - public function getModelId(): int - { - return $this->id; - } - - public function getModelName(): ?string - { - return $this->name; - } -} - -@endverbatim - -Once a model uses this trait, all queries are **automatically filtered** by the user's authorized organization nodes. `School::all()` returns only schools the user has access to. - -### Making Models ABAC-Filterable - -Implement `AAuthABACModelInterface` and use the `AAuthABACModel` trait: - -@verbatim - -use AuroraWebSoftware\AAuth\Interfaces\AAuthABACModelInterface; -use AuroraWebSoftware\AAuth\Traits\AAuthABACModel; -use Illuminate\Database\Eloquent\Model; - -class Order extends Model implements AAuthABACModelInterface -{ - use AAuthABACModel; - - public static function getModelType(): string - { - return 'order'; - } - - public static function getABACRules(): array - { - return [ - '&&' => [ - ['=' => ['attribute' => 'status', 'value' => 'active']], - ], - ]; - } -} - -@endverbatim - -### Materialized Path Pattern - -AAuth stores the full hierarchy path in a single column for each organization node. This eliminates recursive queries and makes filtering extremely fast. - -**Path Format:** `parent_id/current_id` (e.g., `1/5/7`) - -@verbatim - -Organization Node ID: 1 (School District) Path: 1 -├── ID: 5 (School A) Path: 1/5 -│ ├── ID: 7 (Math Dept) Path: 1/5/7 -│ │ ├── ID: 9 (Student: John) Path: 1/5/7/9 -│ │ └── ID: 10 (Student: Jane) Path: 1/5/7/10 -│ └── ID: 8 (Science Dept) Path: 1/5/8 -│ └── ID: 11 (Student: Bob) Path: 1/5/8/11 -└── ID: 6 (School B) Path: 1/6 - -@endverbatim - -The `AAuthOrganizationNodeScope` global scope automatically adds WHERE clauses using LIKE pattern matching on the path column: - -@verbatim - -// Teacher assigned to Math Dept (node 7, path: 1/5/7) -$students = Student::all(); - -// AAuth transforms this to: -// SELECT students.* FROM students -// INNER JOIN organization_nodes ON organization_nodes.model_id = students.id -// WHERE organization_nodes.model_type = 'App\Models\Student' -// AND (organization_nodes.path LIKE '1/5/7/%' OR organization_nodes.path = '1/5/7') -// -// Returns only John and Jane (not Bob from Science Dept) - -@endverbatim - -### Multiple Organization Node Access - -Users can be assigned to **multiple organization nodes simultaneously**. AAuth combines all authorized paths with OR conditions: - -@verbatim - -// Assign teacher to BOTH Math and Science departments -$rolePermissionService->attachOrganizationRoleToUser(7, $teacherRole->id, $teacher->id); -$rolePermissionService->attachOrganizationRoleToUser(8, $teacherRole->id, $teacher->id); - -// Query automatically combines with OR: -// WHERE (path LIKE '1/5/7/%' OR path = '1/5/7' OR path LIKE '1/5/8/%' OR path = '1/5/8') -$students = Student::all(); // Returns students from BOTH departments - -@endverbatim - -### Creating Organization Structure - -@verbatim - -use AuroraWebSoftware\AAuth\Services\OrganizationService; - -$organizationService = new OrganizationService(); - -$scope = $organizationService->createOrganizationScope([ - 'name' => 'School System', - 'level' => 1, - 'status' => 'active', -]); - -$node = $organizationService->createOrganizationNode([ - 'name' => 'High School A', - 'organization_scope_id' => $scope->id, - 'parent_id' => null, // Root node -]); - -@endverbatim - -`OrganizationService` also provides: `updateOrganizationScope()`, `deleteOrganizationScope()`, `getPath()`, `updateNodePathsRecursively()`, `deleteOrganizationNodesRecursively()`. - -### Creating Roles and Permissions - -@verbatim - -use AuroraWebSoftware\AAuth\Services\RolePermissionService; - -$rolePermissionService = new RolePermissionService(); - -$role = $rolePermissionService->createRole([ - 'organization_scope_id' => $organizationScope->id, - 'type' => 'system', // or 'organization' - 'name' => 'System Administrator', - 'status' => 'active', -]); - -// Attach single permission -$rolePermissionService->attachPermissionToRole('edit_something', $role->id); - -// Attach multiple permissions -$rolePermissionService->attachPermissionToRole([ - 'create_something', 'edit_something', 'delete_something', -], $role->id); - -// Sync permissions (replaces all existing) -$rolePermissionService->syncPermissionsOfRole(['create_something', 'edit_something'], $role->id); - -// Detach permissions -$rolePermissionService->detachPermissionFromRole('edit_something', $role->id); -$rolePermissionService->detachAllPermissionsFromRole($role->id); - -@endverbatim - -### Assigning Roles to Users - -@verbatim - -// System roles (organization-independent) -$rolePermissionService->attachSystemRoleToUser($role->id, $user->id); -$rolePermissionService->attachSystemRoleToUser([$role1->id, $role2->id], $user->id); -$rolePermissionService->syncUserSystemRoles($user->id, [$role1->id, $role2->id]); -$rolePermissionService->detachSystemRoleFromUser($role->id, $user->id); - -// Organization roles (require an organization node) -$rolePermissionService->attachOrganizationRoleToUser( - $organizationNode->id, $role->id, $user->id -); -$rolePermissionService->detachOrganizationRoleFromUser($user->id, $role->id, $organizationNode->id); - -@endverbatim - -### Session-Based Role Selection - -AAuth uses session to track the current active role. The `roleId` must be set in session before using AAuth: - -@verbatim - -use Illuminate\Support\Facades\Session; - -Session::put('roleId', $role->id); - -@endverbatim - -### Using AAuth Facade - Permission Checks - -@verbatim - -use AuroraWebSoftware\AAuth\Facades\AAuth; - -// Check permission -if (AAuth::can('edit_something')) { /* ... */ } - -// Check with parametric arguments -if (AAuth::can('posts.edit', 5, 'draft')) { /* ... */ } - -// Abort if no permission (returns 401) -AAuth::passOrAbort('edit_something', 'You do not have permission'); - -// Get all permissions for current role -$permissions = AAuth::permissions(); -$orgPermissions = AAuth::organizationPermissions(); -$sysPermissions = AAuth::systemPermissions(); - -// Check super admin -if (AAuth::isSuperAdmin()) { /* bypass all checks */ } - -// Get current role -$role = AAuth::currentRole(); - -// Get switchable roles for current user -$roles = AAuth::switchableRoles(); -$roles = AAuth::switchableRolesStatic($userId); - -@endverbatim - -### Using AAuth Facade - Organization Nodes - -@verbatim - -// Get all authorized organization nodes -$nodes = AAuth::organizationNodes(); -$nodes = AAuth::organizationNodes(includeRootNode: true); -$nodes = AAuth::organizationNodes(modelType: School::class); - -// Get query builder for custom queries -$query = AAuth::organizationNodesQuery(includeRootNode: true, modelType: School::class); - -// Get specific authorized node (throws InvalidOrganizationNodeException if not authorized) -$node = AAuth::organizationNode(nodeId: 5); - -// Get array of authorized node IDs -$ids = AAuth::organizationNodeIds(); - -// Check descendant relationship -$isDescendant = AAuth::descendant(rootNodeId: 1, childNodeId: 5); - -// Advanced: filter by depth and scope -$nodes = AAuth::getAccessibleOrganizationNodes( - minDepthFromRoot: 1, - maxDepthFromRoot: 3, - scopeName: 'Department', - scopeLevel: 2, - includeRootNode: true, - modelType: School::class, -); - -@endverbatim - -### Using AAuth Facade - ABAC Rules - -@verbatim - -use AuroraWebSoftware\AAuth\Models\RoleModelAbacRule; - -// Get ABAC rules for a model type -$rules = AAuth::ABACRules('order'); - -// Create ABAC rule linking a role to a model type -RoleModelAbacRule::create([ - 'role_id' => $role->id, - 'model_type' => Order::getModelType(), - 'rules_json' => [ - '&&' => [ - ['=' => ['attribute' => 'status', 'value' => 'approved']], - ['>=' => ['attribute' => 'amount', 'value' => 100]], - ['||' => [ - ['=' => ['attribute' => 'category', 'value' => 'electronics']], - ['=' => ['attribute' => 'category', 'value' => 'books']], - ]], - ], - ], -]); - -// ABAC operators: =, !=, >, <, >=, <=, like -// Logical operators: && (AND), || (OR) - can be nested - -@endverbatim - -### Role Model Methods - -The Role model provides direct methods for permission management: - -@verbatim - -// Give a permission (with optional parametric arguments) -$role->givePermission('edit_posts'); -$role->givePermission('edit_posts', ['max_edits_per_day' => 10, 'allowed_statuses' => ['draft']]); - -// Remove a permission -$role->removePermission('edit_posts'); - -// Sync permissions (replaces all existing) -$role->syncPermissions(['view_posts', 'edit_posts', 'delete_posts']); - -// Sync with parameters -$role->syncPermissions([ - 'view_posts' => null, - 'edit_posts' => ['max_edits_per_day' => 5], -]); - -// Check if role has a permission -if ($role->hasPermission('edit_posts')) { /* ... */ } - -// Relationships -$role->rolePermissions; // HasMany -> RolePermission (with parameters) -$role->abacRules; // HasMany -> RoleModelAbacRule -$role->organization_scope; // BelongsTo -> OrganizationScope -$role->organization_nodes; // BelongsToMany -> OrganizationNode - -// Computed attributes -$role->assigned_user_count; // Number of users assigned to this role -$role->deletable; // true if no users assigned - -@endverbatim - -### Helper Functions - -AAuth provides global helper functions (autoloaded via `src/helpers.php`): - -@verbatim - -// Get AAuth service instance -$aauth = aauth(); - -// Check permission -if (aauth_can('edit_something', $param1, $param2)) { /* ... */ } - -// Check current role name -if (aauth_has_role('System Administrator')) { /* ... */ } - -// Get current active role -$role = aauth_active_role(); // returns ?Role - -// Get first active organization node -$node = aauth_active_organization(); // returns ?OrganizationNode - -// Check super admin status -if (aauth_is_super_admin()) { /* ... */ } - -@endverbatim - -### Creating Models with Organization Nodes - -@verbatim - -// Create model and its organization node together -$school = School::createWithAAuthOrganizationNode( - ['name' => 'New School', 'address' => '123 Main St'], - $parentOrganizationNodeId, - $organizationScopeId -); - -// Update model and sync organization node (static method) -School::updateWithAAuthOrganizationNode( - $modelId, - $nodeId, - ['name' => 'Updated School'], - $parentOrganizationNodeId, - $organizationScopeId -); - -// Delete model and its organization node recursively (static method) -School::deleteWithAAuthOrganizationNode($modelId); - -// Access the related organization node -$orgNode = $school->relatedAAuthOrganizationNode(); - -// Get all records without organization scope filtering (instance method) -$all = (new School)->allWithoutAAuthOrganizationNodeScope(); - -@endverbatim - -### Middleware - -AAuth provides three middleware for route protection: - -@verbatim - -// Permission middleware - checks if user has the given permission -Route::get('/students', [StudentController::class, 'index']) - ->middleware('aauth.permission:view_students'); - -// Permission with parametric arguments -Route::put('/posts/{post}', [PostController::class, 'update']) - ->middleware('aauth.permission:posts.edit,5,draft'); - -// Role middleware - checks if current active role matches -Route::get('/admin', [AdminController::class, 'index']) - ->middleware('aauth.role:System Administrator'); - -// Organization scope middleware - checks if user's role belongs to given scope -Route::get('/department', [DeptController::class, 'index']) - ->middleware('aauth.organization:2'); // organization_scope_id = 2 - -@endverbatim - -### Blade Directives - -@verbatim - -{{-- Permission check --}} -@@aauth('edit_students') - -@@endaauth - -{{-- Permission check with @if-style syntax --}} -@@aauth_can('create_students') - -@@endaauth_can - -{{-- Role check --}} -@@aauth_role('System Administrator') - Admin Panel -@@endaauth_role - -{{-- Super admin check --}} -@@aauth_super_admin - Super Admin Tools -@@endaauth_super_admin - -@endverbatim - -### Laravel Gate Integration - -AAuth integrates with Laravel's built-in authorization via a `Gate::before` callback registered in the service provider: - -@verbatim - -// Registered in AAuthServiceProvider::boot() -Gate::before(function ($user, $ability, $arguments = []) { - $aauth = app('aauth'); - - // Super admin bypasses all permission checks - if ($aauth->isSuperAdmin()) { - return true; - } - - // Delegate to AAuth::can() for all Gate checks - return $aauth->can($ability, ...$arguments) ?: null; -}); - -// This means standard Laravel Gate/Policy checks work with AAuth: -Gate::allows('edit_something'); -$user->can('edit_something'); -@can('edit_something') ... @endcan - -@endverbatim - -### Events - -AAuth dispatches events for role and permission lifecycle: - -@verbatim - -use AuroraWebSoftware\AAuth\Events\RoleCreatedEvent; // Role $role -use AuroraWebSoftware\AAuth\Events\RoleUpdatedEvent; // Role $role -use AuroraWebSoftware\AAuth\Events\RoleDeletedEvent; // Role $role -use AuroraWebSoftware\AAuth\Events\RoleAssignedEvent; // int $userId, Role $role, ?OrganizationNode $organizationNode -use AuroraWebSoftware\AAuth\Events\RoleRemovedEvent; // int $userId, Role $role, ?OrganizationNode $organizationNode -use AuroraWebSoftware\AAuth\Events\RoleSwitchedEvent; // int $userId, Role $newRole, ?Role $oldRole, ?OrganizationNode $organizationNode -use AuroraWebSoftware\AAuth\Events\PermissionAddedEvent; // Role $role, string $permission, ?array $parameters -use AuroraWebSoftware\AAuth\Events\PermissionUpdatedEvent; // Role $role, string $permission, ?array $parameters, ?array $oldParameters -use AuroraWebSoftware\AAuth\Events\PermissionRemovedEvent; // Role $role, string $permission - -// Listen to events in your EventServiceProvider or listener classes - -@endverbatim - -### Caching - -AAuth uses two levels of caching: - -1. **Request-level**: Laravel `Context` and in-memory `requestCache` for per-request deduplication -2. **Cross-request**: Configurable cache (via `aauth-advanced.cache`) for role data and switchable roles - -Cache is **automatically invalidated** by `RoleObserver` and `RolePermissionObserver` when roles or permissions change. You can also manually clear: - -@verbatim - -// Clear request-level context cache -app('aauth')->clearContext(); - -@endverbatim - -### Bypassing Organization Filtering - -@verbatim - -use AuroraWebSoftware\AAuth\Scopes\AAuthOrganizationNodeScope; - -// Remove only organization scope -$allSchools = School::withoutGlobalScope(AAuthOrganizationNodeScope::class)->get(); - -// Remove all global scopes (including ABAC) -$allSchools = School::withoutGlobalScopes()->get(); - -@endverbatim - -### Database Tables - -AAuth creates these tables: - -- **`roles`** - id, organization_scope_id, type (system/organization), name, status -- **`role_permission`** - id, role_id, permission, parameters (JSON) -- **`user_role_organization_node`** - id, user_id, role_id, organization_node_id -- **`organization_scopes`** - id, name, level, status -- **`organization_nodes`** - id, organization_scope_id, name, model_type, model_id, path, parent_id -- **`role_model_abac_rules`** - id, role_id, model_type, rules_json (JSON) - -### Key Enums - -@verbatim - -use AuroraWebSoftware\AAuth\Enums\RoleType; // system, organization -use AuroraWebSoftware\AAuth\Enums\ActivityStatus; // active, passive -use AuroraWebSoftware\AAuth\Enums\ABACCondition; // =, !=, >, <, >=, <=, like -use AuroraWebSoftware\AAuth\Enums\ABACLogicalOperator; // && (and), || (or) - -@endverbatim - -### Exceptions - -@verbatim - -use AuroraWebSoftware\AAuth\Exceptions\AuthorizationException; -use AuroraWebSoftware\AAuth\Exceptions\InvalidOrganizationNodeException; -use AuroraWebSoftware\AAuth\Exceptions\InvalidOrganizationScopeException; -use AuroraWebSoftware\AAuth\Exceptions\InvalidRoleException; -use AuroraWebSoftware\AAuth\Exceptions\InvalidRoleTypeException; -use AuroraWebSoftware\AAuth\Exceptions\InvalidUserException; -use AuroraWebSoftware\AAuth\Exceptions\MissingRoleException; -use AuroraWebSoftware\AAuth\Exceptions\OrganizationNodeAuthException; -use AuroraWebSoftware\AAuth\Exceptions\OrganizationScopesMismatchException; -use AuroraWebSoftware\AAuth\Exceptions\UserHasNoAssignedRoleException; - -@endverbatim - -### Internationalization (i18n) - -AAuth supports multiple languages for exception messages. Language files are at `resources/lang/{locale}/aauth.php`. - -@verbatim - -// Available languages: en, tr -// Publish language files: -// php artisan vendor:publish --tag="aauth-lang" - -// Exception message keys: -// aauth.authorization, aauth.invalid_location_scopes, aauth.invalid_organization_node, -// aauth.invalid_organization_scope, aauth.invalid_role, aauth.invalid_role_type, -// aauth.invalid_user, aauth.missing_role, aauth.organization_node_auth, -// aauth.organization_scopes_mismatch, aauth.user_has_no_assigned_role - -@endverbatim - -### Observers - -AAuth registers two observers that handle cache invalidation and event dispatching: - -- **RoleObserver**: On `created` fires `RoleCreatedEvent`, on `updated` clears role cache + fires `RoleUpdatedEvent`, on `deleted` clears role cache + clears affected users' switchable roles cache + fires `RoleDeletedEvent` -- **RolePermissionObserver**: On `created/updated/deleted` clears AAuth context (request cache) + clears role cache + fires `PermissionAddedEvent`/`PermissionUpdatedEvent`/`PermissionRemovedEvent` - -### Validation Request Classes - -AAuth provides FormRequest classes for validation: - -- `StoreRoleRequest` / `UpdateRoleRequest` - name: required, min:3 -- `StoreOrganizationNodeRequest` / `UpdateOrganizationNodeRequest` - name: required, min:3; parent_id: required, int -- `StoreOrganizationScopeRequest` / `UpdateOrganizationScopeRequest` - name: required, min:3; level: optional - -### Best Practices - -1. **Always set `roleId` in session** before using AAuth facade or any model with AAuth traits -2. **Use organization roles** for data belonging to organizational hierarchy -3. **Use system roles** for global permissions independent of organization -4. **Use middleware** for route-level protection instead of manual checks in controllers -5. **Use `AAuth::passOrAbort()`** in controllers/services for programmatic permission checks -6. **Use `withoutGlobalScope()`** when bypassing organization or ABAC filtering for admin operations -7. **Use transactions** when creating models with organization nodes for data consistency -8. **Use parametric permissions** for fine-grained control (e.g., max edits, allowed statuses) -9. **Listen to events** for audit logging and side effects on role/permission changes -10. **Enable cache** in production via `aauth-advanced.cache.enabled` for better performance - -### Important Notes - -- System permissions can only be assigned to system roles -- Organization permissions can only be assigned to organization roles -- Models using `AAuthOrganizationNode` trait automatically filter data via global scope -- Models using `AAuthABACModel` trait automatically filter data via ABAC global scope -- Organization nodes use path-based hierarchy (e.g., `1/5/7`) -- Users can have multiple roles, but only one active role per session -- The `Gate::before` callback integrates AAuth with Laravel's built-in authorization -- Cache is automatically invalidated when roles or permissions change -- Super admin (when enabled) bypasses all permission checks