Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .claude/rules/backend/api-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
paths:
- "backend/app/**/ApiKey*/**"
- "backend/app/**/*ApiKey*.php"
- "backend/app/Services/Domain/Auth/AuthUserService.php"
- "backend/app/Http/Middleware/**"
- "backend/routes/api.php"
- "backend/config/sanctum.php"
- "backend/tests/Feature/Http/ApiKeys/**"
- "frontend/src/components/routes/settings/ApiKeys/**"
- "frontend/src/components/routes/settings/ApiReference/**"
- "e2e/tests/account/api-keys.spec.ts"
---

# API keys

- API keys are Sanctum personal access tokens owned by a user and scoped to one account (`personal_access_tokens.account_id`). A key acts as that member: their role, grants and permission overrides apply, and it can never do more. Read-only keys carry only the `read` ability; `EnforceAccountUserAccess` blocks their writes with `API_KEY_READ_ONLY` (exports and previews marked `#[AllowedForReadOnlyUsers]` stay open).
- Signed-in routes live in two groups. `auth:api,sanctum` accepts keys; `auth:api` is session-only: auth, profile and sign-in security, team, account settings and security, payouts (Stripe Connect, terminal readers, VAT) and API key management. A new route that changes who can sign in, team membership, money destinations or account security goes in the session-only group; `ApiKeyRouteExposureTest` snapshots that group, and no `MANAGE_TEAM`, `MANAGE_ACCOUNT` or `MANAGE_PAYMENTS` action may accept keys.
- Never read JWT claims with `Auth::payload()` on a route keys can reach: it throws under the Sanctum guard. Use `AuthUserService` (`getAuthenticatedAccountId`, `isImpersonating`, `getSessionTokenId`, `logout`). The access cache key for a key request is `api-key:{id}`.
- A key works only while its owner is an ACTIVE member of its account (Sanctum `authenticateAccessTokensUsing` in `AppServiceProvider`); deactivating a member deletes their keys, and so does every `UserSessionRevocationService` call (password reset, SSO link changes, enforcement), because a key must never outlive a credential reset. Revocations driven by one account's SSO settings pass that account id, so only its keys go. A voluntary password change or turning 2FA off keeps keys on purpose (as GitHub and Stripe do); the key-created email tells the owner to revoke a key they didn't create. The account's 2FA requirement and pending deletion also block keys.
- Only the SHA-256 hash and the last four characters are stored; the plain key is returned once, on create. Creating a key needs a verified email and `IdentityConfirmationService`, is refused while impersonating, and is capped at `MAX_ACTIVE_KEYS_PER_MEMBER`. Key names are restricted to a markdown-safe character set because they appear in emails.
- The in-app API reference (`/settings/api-keys/reference`) renders the committed `resources/api-reference/openapi.json` with Scalar, themed through `scalarTheme.ts`. Keep Scalar's AI agent, MCP and telemetry switched off, and load it client-only (it touches `window` on import).
4 changes: 4 additions & 0 deletions .claude/rules/backend/openapi.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
---
paths:
- "backend/resources/api-reference/**"
- "backend/app/OpenApi/**"
- "backend/app/Providers/ScrambleServiceProvider.php"
- "backend/config/scramble.php"
Expand All @@ -14,3 +15,6 @@ paths:
- Status/type fields in the Order/Event/Attendee/Product resources carry `/** @var 'A'|'B' */` literal-union annotations that render as enums; update them when adding enum cases.
- `CreateOrderRequest`/`CompleteOrderRequest` return their real rules only when a route is bound and a static documentation shape otherwise; keep the two in sync when changing checkout validation.
- Adding a `DomainEventType` case means updating the webhook payload table in `config/scramble.php`.
- `backend/resources/api-reference/openapi.json` is the generated in-app API reference (Settings → API keys → API reference): Scramble output minus session-only operations and `/auth/*`, without `servers` and `info.version` (added when served). Regenerate it with `php artisan api-reference:export`; `IntegrationApiReferenceTest` fails when it drifts. Never edit it by hand.
- The docs intro in `config/scramble.php` promises that endpoints API keys can call stay backward compatible (see "Public API compatibility" in AGENTS.md) and lists the error codes. Update its error table when adding an `error_code`. Session-only operations are marked "Not available to API keys" automatically by `ScrambleServiceProvider`.
- Admin routes and the licensing endpoints (`Enterprise\Licensing` actions) are excluded from all generated docs in `ScrambleServiceProvider`.
2 changes: 2 additions & 0 deletions .claude/rules/backend/sso.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,7 @@ paths:
- Every outbound request to a provider goes through `SsrfGuard` (https, public addresses only, pinned with `CURLOPT_RESOLVE`) unless `SSO_ALLOW_PRIVATE_ISSUERS` is set; never set it on multi-tenant installs.
- Domain status is derived from `verified_at` and `failing_since` (suspended seven days after the daily check starts failing); one account per verified domain, conflicts are resolved with `sso:verify-domain`.
- Enforcement is `account_sso_connections.enforce_sso` plus the `account_users.sso_enforced` snapshot (set at enable and when someone joins, cleared only by an admin's exempt toggle). It's checked in `LoginService::completeLogin` and `EnsureTokenNotRevoked`; tokens must carry the account's `sso_connection_id`, and a token issued through a connection dies when that connection is turned off or deleted. A member the connection can't sign in (owner of any account or superadmin, with no identity on it) is not enforced, so they keep their password; the preview reports them as `sso_available: false`. A verified domain can't be removed while enforcing. The owner is never enforced: their password sign-in always asks for 2FA, emails every admin (the owner gets their own copy), and neither they nor support can turn their 2FA off while enforcing.
- Forgot-password silently sends nothing when `isPasswordResetAllowed` is false; `/users/me` exposes the same answer as `password_reset_allowed` so Security settings doesn't offer a password link that never arrives.
- Domain capture: while a connection enforces, nobody can sign up with an email on its usable domains. `SsoEnforcementService::assertSignupAllowed` throws `SsoRequiredException` from `CreateAccountHandler` (password and social signup) and before a social signup ticket is issued; the frontend sends them to SSO discovery. Invitations to other accounts are not captured.
- Tokens issued through a connection use its session length and can't be refreshed (`SSO_SESSION_EXPIRED`); the frontend re-authenticates with `prompt=none`.
- Configuration endpoints need `Permission::MANAGE_ACCOUNT` and `FeatureFlag::ENTERPRISE_SSO` (SaaS only); sign-in through an existing connection is never flag-gated. Owners are emailed when someone else changes the provider or enforcement.
2 changes: 1 addition & 1 deletion .claude/rules/backend/team-access.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,5 @@ paths:
- Treat any value that unlocks a public page as a credential. Staff responses return `orders.short_id` only to members with EDIT + VIEW_FINANCIALS; check-in list links are shareable with door staff on purpose.
- Deliberate, don't "fix": VIEW_FINANCIALS hides money in the dashboard but doesn't stop an EDIT member from configuring outbound channels (organizer email, email templates, analytics pixels) or running the door, which shows door sales.
- Public "same account" checks (draft previews, test orders, check-in staff view) use `AccountUserAccessService::currentMemberCanAccessEvent/Organizer`, never a raw account id comparison.
- `AccountUserAccessService` caches access per request keyed by the JWT string; never key it on decoded payload claims (resources call `isPermitted()` hundreds of times per list).
- `AccountUserAccessService` caches access per request keyed by `AuthUserService::getAuthenticatedTokenKey()` (the JWT string, or `api-key:{id}` for API keys); never key it on decoded payload claims (resources call `isPermitted()` hundreds of times per list).
- Frontend: 403s carrying `ACCESS_RESTRICTED`, `PERMISSION_REQUIRED` or `READ_ONLY_ROLE` must never send the user to login.
8 changes: 7 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Backend commands run in the `backend` container from `docker/development` (start
```bash
docker compose -f docker-compose.dev.yml exec backend php artisan migrate
docker compose -f docker-compose.dev.yml exec backend php artisan generate-domain-objects
docker compose -f docker-compose.dev.yml exec backend php artisan api-reference:export
docker compose -f docker-compose.dev.yml exec -T backend php -d memory_limit=1G vendor/bin/phpunit --no-coverage --testsuite=Unit
docker compose -f docker-compose.dev.yml exec -T backend php -d memory_limit=1G vendor/bin/phpunit --no-coverage --filter=TestName
docker compose -f docker-compose.dev.yml exec backend ./vendor/bin/pint --test
Expand All @@ -31,7 +32,7 @@ yarn messages:extract # then add translations for every language (translations

Schema changes: `php artisan make:migration`, `php artisan migrate`, then `php artisan generate-domain-objects`. Domain objects are generated; never edit them by hand.

Before finishing: `npx tsc --noEmit` (no new errors in files you changed), `pint --test`, the Unit suite, and the Feature suite before opening a PR (CI runs both). For a changed endpoint, also smoke-test it against the dev stack (api-smoke-test skill).
Before finishing: `npx tsc --noEmit` (no new errors in files you changed), `pint --test`, the Unit suite, and the Feature suite before opening a PR (CI runs both). For a changed endpoint, also run `php artisan api-reference:export` (the committed in-app reference must match the code; a Feature test enforces it) and smoke-test it against the dev stack (api-smoke-test skill).

## Code standards

Expand All @@ -52,6 +53,10 @@ Before finishing: `npx tsc --noEmit` (no new errors in files you changed), `pint
- Feature flags gate only in SaaS mode: `FeatureFlag` enum + a migration inserting its row (`enabled_by_default = true` for a live feature) + frontend label/constant; check with `FeatureFlagService::assertEnabled` / `useIsFeatureEnabled`.
- Laravel event auto-discovery is load-bearing: most listeners are registered only by it, so never turn off `shouldDiscoverEvents()`.

## Public API compatibility

Integrations call the API with API keys, so endpoints in the `auth:api,sanctum` route group, everything under `/public/*`, webhook payloads and `error_code` values are a public contract. Adding endpoints, optional request fields and response fields is fine. Removing or renaming a route, field or error code, changing a field's type, format or meaning, making an optional field required, tightening validation, or moving a route into the session-only group is a breaking change: avoid it, and when it's unavoidable, tell the user before making it and call it out in the PR description and release notes. Keep the resources' OpenAPI annotations accurate (`.claude/rules/backend/openapi.md`); review the diff of `backend/resources/api-reference/openapi.json` for anything removed or changed.

## Data rules

- Event dates come from `event_occurrences`. The `events.start_date`/`end_date` columns exist only for a v1 rollback: never read or write them.
Expand Down Expand Up @@ -98,6 +103,7 @@ Feature rules live in `.claude/rules/`; Claude Code loads each file automaticall
| Lifecycle emails | `.claude/rules/backend/lifecycle-emails.md` |
| Two-factor authentication | `.claude/rules/backend/two-factor.md` |
| Single sign-on (OIDC) | `.claude/rules/backend/sso.md` |
| API keys | `.claude/rules/backend/api-keys.md` |
| Abuse & spam | `.claude/rules/backend/abuse-and-spam.md` |
| Account deletion | `.claude/rules/backend/account-deletion.md` |
| OpenAPI docs | `.claude/rules/backend/openapi.md` |
Expand Down
38 changes: 38 additions & 0 deletions backend/app/Console/Commands/ExportApiReferenceCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php

namespace HiEvents\Console\Commands;

use HiEvents\OpenApi\IntegrationApiReference;
use Illuminate\Console\Command;

class ExportApiReferenceCommand extends Command
{
protected $signature = 'api-reference:export {--check : Fail if the committed reference is out of date instead of writing it}';

protected $description = 'Write the in-app API reference (the endpoints API keys can call) to resources/api-reference/openapi.json.';

public function handle(IntegrationApiReference $reference): int
{
ini_set('memory_limit', '1G');

$generated = $reference->generate();
$committed = is_file($reference->path()) ? file_get_contents($reference->path()) : null;

if ($this->option('check')) {
if ($generated === $committed) {
$this->info('The API reference is up to date.');

return self::SUCCESS;
}

$this->error('The API reference is out of date. Run `php artisan api-reference:export` and commit resources/api-reference/openapi.json.');

return self::FAILURE;
}

file_put_contents($reference->path(), $generated);
$this->info('Wrote '.$reference->path());

return self::SUCCESS;
}
}
3 changes: 3 additions & 0 deletions backend/app/Console/Kernel.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use HiEvents\Enterprise\Licensing\Console\IssueLicenceKeyCommand;
use HiEvents\Jobs\Abuse\ReportSuspiciousActivityJob;
use HiEvents\Jobs\Account\ProcessScheduledAccountDeletionsJob;
use HiEvents\Jobs\ApiKey\SendApiKeyExpiryRemindersJob;
use HiEvents\Jobs\Auth\RecheckSsoDomainsJob;
use HiEvents\Jobs\Lifecycle\SendLifecycleNudgesJob;
use HiEvents\Jobs\Message\SendScheduledMessagesJob;
Expand All @@ -29,6 +30,8 @@ protected function schedule(Schedule $schedule): void
$schedule->job(new ProcessExpiredWaitlistOffersJob)->everyMinute()->withoutOverlapping();
$schedule->job(new ProcessScheduledAccountDeletionsJob)->hourly()->withoutOverlapping();
$schedule->job(new RecheckSsoDomainsJob)->daily()->withoutOverlapping();
$schedule->job(new SendApiKeyExpiryRemindersJob)->dailyAt('09:00')->withoutOverlapping();
$schedule->command('sanctum:prune-expired', ['--hours' => 24 * 30])->daily();
$schedule->job(new ReportSuspiciousActivityJob)->everyFiveMinutes()->withoutOverlapping()->when(fn () => (bool) config('app.saas_mode_enabled'));
$schedule->job(new SendLifecycleNudgesJob)->hourly()->withoutOverlapping()
->when(static fn (): bool => app(LifecycleEmailSender::class)->isConfigured());
Expand Down
34 changes: 34 additions & 0 deletions backend/app/DomainObjects/Enums/ApiKeyAccess.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

namespace HiEvents\DomainObjects\Enums;

enum ApiKeyAccess: string
{
use BaseEnum;

public const READ_ABILITY = 'read';

public const WRITE_ABILITY = 'write';

case READ_ONLY = 'READ_ONLY';
case READ_WRITE = 'READ_WRITE';

/**
* @return string[]
*/
public function abilities(): array
{
return match ($this) {
self::READ_ONLY => [self::READ_ABILITY],
self::READ_WRITE => [self::READ_ABILITY, self::WRITE_ABILITY],
};
}

/**
* @param string[] $abilities
*/
public static function fromAbilities(array $abilities): self
{
return in_array(self::WRITE_ABILITY, $abilities, true) ? self::READ_WRITE : self::READ_ONLY;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ abstract class PersonalAccessTokenDomainObjectAbstract extends \HiEvents\DomainO
final public const SINGULAR_NAME = 'personal_access_token';
final public const PLURAL_NAME = 'personal_access_tokens';
final public const ID = 'id';
final public const ACCOUNT_ID = 'account_id';
final public const TOKENABLE_TYPE = 'tokenable_type';
final public const TOKENABLE_ID = 'tokenable_id';
final public const NAME = 'name';
Expand All @@ -20,8 +21,11 @@ abstract class PersonalAccessTokenDomainObjectAbstract extends \HiEvents\DomainO
final public const EXPIRES_AT = 'expires_at';
final public const CREATED_AT = 'created_at';
final public const UPDATED_AT = 'updated_at';
final public const TOKEN_HINT = 'token_hint';
final public const EXPIRY_REMINDER_SENT_AT = 'expiry_reminder_sent_at';

protected int $id;
protected int $account_id;
protected string $tokenable_type;
protected int $tokenable_id;
protected string $name;
Expand All @@ -31,11 +35,14 @@ abstract class PersonalAccessTokenDomainObjectAbstract extends \HiEvents\DomainO
protected ?string $expires_at = null;
protected ?string $created_at = null;
protected ?string $updated_at = null;
protected string $token_hint;
protected ?string $expiry_reminder_sent_at = null;

public function toArray(): array
{
return [
'id' => $this->id ?? null,
'account_id' => $this->account_id ?? null,
'tokenable_type' => $this->tokenable_type ?? null,
'tokenable_id' => $this->tokenable_id ?? null,
'name' => $this->name ?? null,
Expand All @@ -45,6 +52,8 @@ public function toArray(): array
'expires_at' => $this->expires_at ?? null,
'created_at' => $this->created_at ?? null,
'updated_at' => $this->updated_at ?? null,
'token_hint' => $this->token_hint ?? null,
'expiry_reminder_sent_at' => $this->expiry_reminder_sent_at ?? null,
];
}

Expand All @@ -59,6 +68,17 @@ public function getId(): int
return $this->id;
}

public function setAccountId(int $account_id): self
{
$this->account_id = $account_id;
return $this;
}

public function getAccountId(): int
{
return $this->account_id;
}

public function setTokenableType(string $tokenable_type): self
{
$this->tokenable_type = $tokenable_type;
Expand Down Expand Up @@ -157,4 +177,26 @@ public function getUpdatedAt(): ?string
{
return $this->updated_at;
}

public function setTokenHint(string $token_hint): self
{
$this->token_hint = $token_hint;
return $this;
}

public function getTokenHint(): string
{
return $this->token_hint;
}

public function setExpiryReminderSentAt(?string $expiry_reminder_sent_at): self
{
$this->expiry_reminder_sent_at = $expiry_reminder_sent_at;
return $this;
}

public function getExpiryReminderSentAt(): ?string
{
return $this->expiry_reminder_sent_at;
}
}
24 changes: 23 additions & 1 deletion backend/app/DomainObjects/PersonalAccessTokenDomainObject.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,26 @@

namespace HiEvents\DomainObjects;

class PersonalAccessTokenDomainObject extends Generated\PersonalAccessTokenDomainObjectAbstract {}
use HiEvents\DomainObjects\Enums\ApiKeyAccess;

class PersonalAccessTokenDomainObject extends Generated\PersonalAccessTokenDomainObjectAbstract
{
private ?UserDomainObject $user = null;

public function getAccess(): ApiKeyAccess
{
return ApiKeyAccess::fromAbilities(json_decode($this->getAbilities() ?? '[]', true) ?: []);
}

public function setUser(?UserDomainObject $user): self
{
$this->user = $user;

return $this;
}

public function getUser(): ?UserDomainObject
{
return $this->user;
}
}
15 changes: 15 additions & 0 deletions backend/app/Exceptions/ApiKeyNotAllowedException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

namespace HiEvents\Exceptions;

use Throwable;

class ApiKeyNotAllowedException extends CodedAccessException
{
public const ERROR_CODE = 'API_KEY_NOT_ALLOWED';

public function __construct(?string $message = null, ?Throwable $previous = null)
{
parent::__construct($message ?? __('API keys can\'t be used for this request. Sign in to do this instead.'), $previous);
}
}
15 changes: 15 additions & 0 deletions backend/app/Exceptions/ApiKeyReadOnlyException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

namespace HiEvents\Exceptions;

use Throwable;

class ApiKeyReadOnlyException extends CodedAccessException
{
public const ERROR_CODE = 'API_KEY_READ_ONLY';

public function __construct(?string $message = null, ?Throwable $previous = null)
{
parent::__construct($message ?? __('This API key is read-only. Create a key with read and write access to make changes.'), $previous);
}
}
Loading
Loading