Add a protected API
Extend Note without losing action permissions or record scope.
Add a protected API
Protect Note updates at the endpoint. Create a Resource already generates the permission, Policy, routes and controller. Start by opening src/Policies/NotePolicy.php in your App; its update method combines both required checks:
public function update(Actor $user, Note $note): bool
{
return $this->allowsPermission($user, 'workshop.note.update')
&& $this->matchesAuthorizationContract($note);
}The imports and private helpers are already generated. In src/Http/Controllers/NoteController.php, keep the sequence: resolve the record by public UUID, restore its stored owner, authorize update, validate the input, then write. To accept another field, use the complete Data Model and Migrations.
Add an archive action
This example permits archiving a draft or active Note and returns 409 for a repeated archive. In the App-relative files below, add these members to the generated classes and the route inside its existing guarded group. Do not replace the whole files.
// NoteModule::permissionResources(): keep the existing actions and add archive.
return [
'workshop.note' => ['read', 'create', 'update', 'delete', 'archive'],
];// Add to NotePolicy; reuse its existing Actor, Note and authorization helpers.
public function archive(Actor $user, Note $note): bool
{
return $this->allowsPermission($user, 'workshop.note.archive')
&& $this->matchesAuthorizationContract($note);
}// routes/routes.php: inside the existing api/workshop prefix and guarded App group.
Route::post('/notes/{note}/archive', [NoteController::class, 'archive'])
->whereUuid('note');// NoteController: add the DB import; the other imports already exist.
use Illuminate\Support\Facades\DB;
public function archive(Request $request, string $note): JsonResponse
{
$record = $this->resolveNote($request, $note, 'workshop.note.archive');
return DB::transaction(function () use ($request, $record): JsonResponse {
$locked = Note::query()->whereKey($record->getKey())
->lockForUpdate()->firstOrFail();
$this->authorize('archive', $locked);
abort_if($locked->status === NoteStatus::Archived, 409);
$locked->update(['status' => NoteStatus::Archived]);
return response()->json([
'note' => $this->serialize(
$request,
$locked,
app(ResourceActionDecisions::class),
),
]);
});
}In NoteController::store() and update(), replace the generated status validation rule with 'status' => ['missing']. New Notes keep the database draft default; ordinary updates cannot bypass the archive permission. The missing rule rejects even an explicitly supplied null or empty status; keep all other field rules. Add permissions.keys.workshop.note.archive.label and .description in every locale: Archive Notes / Allows archiving Notes., 노트 보관 / 노트를 보관할 수 있습니다., 归档笔记 / 允许归档笔记。.
The action accepts no caller-supplied status or owner. The existing resolver restores owner scope and the locked row prevents two concurrent archives from both succeeding. For a button, use Build an App screen and expose an authorized action decision; do not infer authorization from status alone.
After changing declarations, run from the Core root:
task artisan -- nexia-access:sync-manifest
task artisan -- nexia-runtime:refresh-runtime-cachesThis synchronizes permission definitions and cached wiring. It does not grant the permission to a user.
Protect collections too
A Policy on one record does not filter a list. The generated model uses ResourceAuthorization::scopeVisible() and the Legal Entity list controller resolves authorized page targets before querying. Preserve those predicates for list, export and bulk work. Filter in SQL before pagination; filtering returned rows leaks totals and loads forbidden data.
Create uses creationAttributes() and one authorized target. Never accept numeric owner IDs from the request or let a request overwrite the stored owner. For standard list pages, the selector belongs in NxPageFrame.organizationScope through NxOrganizationTargetSelector and useOrganizationListScope. Tenant-owned standard common data has no selector.
Verify the HTTP boundary
For an authorized Note, POST /api/workshop/notes/{public_id}/archive returns 200 with status: "archived"; repeating it returns 409. A normal create/update request that supplies status returns 422, so it cannot bypass the archive action.
Use an installed-App tenant fixture and call the endpoint, not only the Policy. Cover an allowed actor, an actor without permission, another organization and another tenant. An out-of-scope record can deliberately resolve to 404; the generated controller conceals some owner-resolution failures. Assert the actual endpoint contract rather than requiring every denial to be 403.
Test an App distinguishes the generated declaration assertion from these behavioral checks. Navigation, disabled buttons and actions response metadata improve UX; the server remains authoritative.