Files
ignacionelson 91d34b204c Let something other than a browser session identify itself to the audit log
An actor with no personal access token has always meant a browser, and
for as long as a session and a Sanctum token were the only two ways to
authenticate, that was true. It stops being true the moment anything else
can, and the failure is silent: the action gets recorded as a person
clicking, in the one table whose whole purpose is answering "did I do
that, or did something acting for me?"

Nothing misreports today — every call site that passes an explicit actor
is a browser request, an API request whose actor carries the token, or a
console command with no actor at all. This closes the trap before the AI
connector in cloud-modules walks into it.

ActivityOrigin is a closed enum, so core has to publish both the case and
the hook before a package can use either. ResolvingActivityOrigin is
asked only in the ambiguous case: a request carrying a token is the API
and a request with nobody signed in is public or system, and neither is
in any doubt, so neither is offered — one package must not be able to
quietly relabel how every integration's actions are attributed.

The person stays the actor. They authorised it, and a log naming the
assistant instead would lose the only fact that matters. What the
connector was called goes in api_token_name, beside a null token id,
because that column means a row in personal_access_tokens and this is not
one.

The new origin is kept out of the activity filter unless the edition can
actually produce it. A filter option that can only ever return nothing is
a feature dangled at an edition that does not have it, which is the one
thing the edition boundary exists not to do.
2026-08-25 14:19:32 -03:00

67 lines
2.3 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Modules\Audit;
/**
* How an audited action reached the application.
*
* Distinct from `actor_type`, which says *who* acted (staff, client, or
* nobody). Origin says *through what*: the same staff member deleting the
* same file from the web UI and from an integration produces two entries
* that are otherwise identical, and an administrator reviewing the log
* needs to tell them apart — "did I do that, or did the Zapier token?" is
* the first question asked when something unexpected shows up.
*/
enum ActivityOrigin: string
{
/** A browser session — the web UI. */
case Ui = 'ui';
/** An API token. `api_token_id` and `api_token_name` are set alongside. */
case Api = 'api';
/**
* A web request with nobody signed in — a visitor commenting on a
* public file today, and whatever else the public surface grows.
*
* Split out of System because the two are not the same thing and were
* being shown with the same word: the scheduler deleting an expired
* file and a stranger leaving a comment both read as "System", which
* made the audit trail claim the installation had commented on its own
* file. Scheduled tasks keep System — they go through
* ActivityLogger::logSystem(), which never asks this method.
*/
case Public = 'public';
/** Scheduled tasks and console commands. */
case System = 'system';
/**
* An AI assistant acting for a signed-in person, through a connector
* they authorised — the code that can produce this ships in
* projectsend/cloud-modules and nowhere else.
*
* The person stays the actor: they authorised it, and an audit trail
* that named the assistant instead would lose the only fact that
* matters when something unexpected shows up. What the connector was
* called goes beside the entry, the way an API token's name does.
*/
case Mcp = 'mcp';
/**
* English label — also the translation key.
*/
public function label(): string
{
return match ($this) {
self::Ui => 'Web UI',
self::Api => 'API',
self::Public => 'Not signed in',
self::System => 'System',
self::Mcp => 'AI assistant',
};
}
}