Skip to content
Guide

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:

Code example
PHP
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.

Code example
PHP
// NoteModule::permissionResources(): keep the existing actions and add archive.
return [
    'workshop.note' => ['read', 'create', 'update', 'delete', 'archive'],
];
Code example
PHP
// 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);
}
Code example
PHP
// routes/routes.php: inside the existing api/workshop prefix and guarded App group.
Route::post('/notes/{note}/archive', [NoteController::class, 'archive'])
    ->whereUuid('note');
Code example
PHP
// 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:

Code example
Shell
task artisan -- nexia-access:sync-manifest
task artisan -- nexia-runtime:refresh-runtime-caches

This 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.

Source of truth: docs/developers/content/en/building-apps/add-api-and-authorization.md