Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
5b8e48d
Remove the abandoned CompanyScope global scope
roncodes Sep 18, 2026
53be27f
chore(deps): raise the PHP floor to ^8.1 and add laravel/socialite
roncodes Sep 20, 2026
4a1beb9
feat(oauth): add the identity model and single-use state store
roncodes Sep 20, 2026
f509acc
feat(oauth): add the provider abstraction and four drivers
roncodes Sep 20, 2026
a291fe6
feat(oauth): add the sign-in endpoints
roncodes Sep 20, 2026
f0f4b8a
feat(oauth): accept a registration intent during signup
roncodes Sep 20, 2026
93f405b
feat(oauth): let administrators configure providers
roncodes Sep 21, 2026
fe806f2
feat(oauth): let users link and unlink providers on their account
roncodes Sep 21, 2026
b194ea3
feat(oauth): check credentials with the provider before offering it
roncodes Sep 21, 2026
c8c7f71
feat(oauth): give every provider settings field an example placeholder
roncodes Sep 21, 2026
d5237b5
feat(oauth): link existing accounts automatically, and email about links
roncodes Sep 21, 2026
9a98911
feat: keep driver, customer and contact accounts out of the console
roncodes Sep 21, 2026
8c94dde
test: cover managed account promotion and impersonation guards
roncodes Sep 21, 2026
b700ffd
chore(release): open v1.6.63 release branch
roncodes Sep 22, 2026
ec25f49
fix: never grant the Administrator role by default
roncodes Sep 22, 2026
1a04733
Note the default Administrator role fix in the v1.6.63 release notes
roncodes Sep 22, 2026
e55369d
feat: let IAM admins request and record email/phone verification
roncodes Sep 22, 2026
41a5ffe
Note contact verification requests in the v1.6.63 release notes
roncodes Sep 22, 2026
820cfea
feat(oauth): support provider buttons on the sign-up page
roncodes Sep 22, 2026
551bd53
fix(oauth): skip email verification for a provider-verified sign-up
roncodes Sep 22, 2026
917c217
Merge pull request #260 from fleetbase/chore/remove-abandoned-company…
roncodes Sep 22, 2026
663b14f
Merge pull request #264 from fleetbase/feature/managed-profile-accounts
roncodes Sep 22, 2026
0e3feed
Merge pull request #266 from fleetbase/fix/no-default-administrator-role
roncodes Sep 22, 2026
4bde958
Merge branch 'release/v1.6.63' into feature/iam-contact-verification
roncodes Sep 22, 2026
b54b46d
Merge pull request #267 from fleetbase/feature/iam-contact-verification
roncodes Sep 22, 2026
3c6dd0f
Merge pull request #261 from fleetbase/feature/oauth
roncodes Sep 22, 2026
8d17c34
fix(oauth): accept provider signing keys that don't name an algorithm
roncodes Sep 22, 2026
0221c87
fix(oauth): ask to sign in and link when a provider email is already …
roncodes Sep 22, 2026
41297af
test(onboard): an OAuth sign-up can't take an email that's already in…
roncodes Sep 22, 2026
d110f76
fix(oauth): read Microsoft's xms_edov however it's serialised, and lo…
roncodes Sep 22, 2026
5fe4dcb
fix(iam): leave profile-managed accounts out of the IAM metrics
roncodes Sep 22, 2026
d0ca698
ci: run PHP CI and the Postman contract once per push, not twice
roncodes Sep 22, 2026
73ab117
test(oauth): cover the token exchange, id token verification and regi…
roncodes Sep 22, 2026
3263482
test(oauth): cover state, identity and registration intent edge cases
roncodes Sep 22, 2026
71387b7
test(oauth): cover the controller's rate limits and mid-flight refusals
roncodes Sep 22, 2026
f5dd0d7
test(iam): cover promoting managed accounts to team members
roncodes Sep 22, 2026
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
10 changes: 9 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,22 @@ name: PHP CI
# Release work lands on a release/v* or dev-v* branch before reaching main via the release
# PR, so a main-only filter leaves every PR targeting a release branch with no CI at
# all — the release is then assembled from unverified commits.
# Pushes run only for main and tags. A release or dev branch always has a pull
# request, and pull_request already runs on every push to it; triggering on push too
# ran the whole suite twice for each commit.
on:
push:
branches: [ main, 'dev-v*', 'release/v*' ]
branches: [ main ]
tags:
- 'v*'
pull_request:
branches: [ main, 'dev-v*', 'release/v*' ]

# A newer push to the same pull request or branch makes the running check stale.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
build:
runs-on: ubuntu-latest
Expand Down
10 changes: 9 additions & 1 deletion .github/workflows/postman.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,20 @@ name: API Contract (Postman)
# fleetbase-ref: v0.7.53
# api-image: fleetbase/fleetbase-api:v0.7.53

# Pushes run only for main. A release or dev branch always has a pull request, and
# pull_request already runs on every push to it; triggering on push too ran the
# ~11-minute contract twice for each commit.
on:
push:
branches: [main, 'dev-v*', 'release/v*']
branches: [main]
pull_request:
branches: [main, 'dev-v*', 'release/v*']
workflow_dispatch:

# A newer push to the same pull request or branch makes the running contract stale.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
permissions:
contents: read

Expand Down
37 changes: 27 additions & 10 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -1,21 +1,38 @@
# v1.6.62 — Custom fields get a public id
# v1.6.63 — Driver, customer and contact accounts stay out of the console

## Improvements

- Give every custom field a public id, so an API that hands one out names it the way the rest of the platform names a resource rather than exposing an internal uuid. `CustomField` takes `HasPublicId` with the `custom_field` prefix, and `public_id` becomes fillable.
- Mint an id on the one path that would otherwise miss it: `HasCustomFields::setCustomField()` saves a field it creates on the fly with `saveQuietly()`, which skips the hook that assigns the id.
- Treat `driver`, `customer` and `contact` users as managed accounts: the FleetOps profile owns them, not IAM. `User` gains `MANAGED_TYPES`, `isManagedAccount()`, `isStaffAccount()`, `canAccessConsole()`, `canHoldConsoleSession()` and a `managed()` scope.
- Promote instead of duplicating. When IAM creates or invites a team member whose email or phone belongs to a managed account in the organization, that account becomes a `user`. It gets the chosen role, permissions and policies and a join invite, and keeps its driver and customer profiles. The response carries `promoted_from`. Accepting any IAM invite also promotes a managed account and asks it to set a console password.

## Improvements for IAM

- Let IAM admins ask a user to verify their email or phone. `POST users/{id}/send-verification` sends a one-click link by email or SMS; it lasts 48 hours. The public `auth/confirm-contact-verification` confirms it without signing in, and refuses the link if the address changed since. `users/verify/{id}` takes a `channel` (email by default, or phone). `UserFilter` adds `email_verified`, `phone_verified`, `country` and `timezone` for the new IAM columns.

## Fixes

- Let an observer's refusal reach the caller on the update and bulk-delete paths. An observer that refused a write by throwing `FleetbaseRequestValidationException` had its explanation discarded: `HasApiModelBehavior::updateRecordFromRequest()` rewrapped every exception from the save as a plain `\Exception`, and `HasApiControllerBehavior::bulkDelete()` caught `\Exception` ahead of its dedicated handler, so callers saw `Invalid request` or a generic update error instead of the message the observer wrote. The exception now passes through untouched on both paths and is rendered with `getErrors()`, as it already was on create and single delete. Every other exception is wrapped exactly as before. Reported in [#256](https://github.com/fleetbase/core-api/issues/256).
- Keep managed accounts out of the console:
- `auth/login` refuses drivers, contacts and customers. Customers keep the `customer_login_not_allowed` code; drivers and contacts get `console_access_not_allowed`.
- Session restore, bootstrap, 2FA verification, verify-email tokens and impersonation refuse drivers and contacts.
- Customers are still allowed on those endpoints because the customer portal runs inside the console and restores its session through them.
- Free a deleted user's email and phone so a new account can use them. On soft delete they move to `meta.deleted_identity`; restoring the user puts them back only if no other account has taken them.

## Reliability
## Security

- Backfill existing rows in the migration, and add the column as nullable and indexed rather than unique-and-required, so it is safe on an already-populated `custom_fields` table.
- Cover id generation for `CustomField`, and add the column to the in-memory schemas whose saves now probe it for uniqueness.
- Never grant the Administrator role by default. Before this fix:
- Creating or inviting a user without a role gave them the Administrator role, and so full organization access. Reported for IAM › Customers › Add customer with a blank Role.
- Accepting an invite with no role also granted it, and `joinOrganization` ignored the invite's role altogether.

Now:
- A role is required when creating or inviting a user; without one the request returns 422.
- `Company::addUser`, `Company::assignUser` and `User::assignCompany` assign no role unless one is given.
- An invite without a role joins with no role.
- Only admins or holders of the Administrator role may grant the Administrator role (403 otherwise), on create, invite and role update.

## Reliability

This is platform-wide: every custom field gains a public id, not only those used by inspections. Nothing reads the new column yet — `withCustomFields()`'s public projection emits field names and is unchanged — so the change is additive for existing consumers.
- Cover the console guards for each account type, identity release and restore, and promotion through create, invite and invite acceptance.

A database migration is required. No configuration change is needed.
A database migration is not required. No configuration change is needed. The FleetOps side ships in fleetbase/fleetops#338.

Changes: [#254](https://github.com/fleetbase/core-api/pull/254), [#259](https://github.com/fleetbase/core-api/pull/259).
Changes: [#264](https://github.com/fleetbase/core-api/pull/264), [#266](https://github.com/fleetbase/core-api/pull/266), [#267](https://github.com/fleetbase/core-api/pull/267).
13 changes: 7 additions & 6 deletions composer.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "fleetbase/core-api",
"version": "1.6.62",
"version": "1.6.63",
"description": "Core Framework and Resources for Fleetbase API",
"keywords": [
"fleetbase",
Expand All @@ -18,7 +18,7 @@
}
],
"require": {
"php": "^8.0",
"php": "^8.1",
"aws/aws-sdk-php-laravel": "^3.7",
"fleetbase/countries": "^0.8.3",
"fleetbase/laravel-mysql-spatial": "^1.0.2",
Expand All @@ -42,23 +42,24 @@
"laravel-notification-channels/fcm": "^4.1",
"laravel-notification-channels/twilio": "^3.3",
"laravel/sanctum": "3.2.4",
"laravel/socialite": "^5.31",
"lcobucci/clock": "3.3.1",
"lcobucci/jwt": "^5.4",
"maatwebsite/excel": "^3.1",
"mossadal/math-parser": "^1.3",
"phpoffice/phpspreadsheet": "^1.28",
"phrity/websocket": "^1.7",
"rlanvin/php-rrule": "^2.4",
"sentry/sentry-laravel": "*",
"spatie/laravel-activitylog": "^4.7",
"spatie/laravel-google-cloud-storage": "^2.2",
"spatie/laravel-pdf": "^1.9",
"spatie/laravel-permission": "^6.3",
"spatie/laravel-responsecache": "^7.5",
"spatie/laravel-schedule-monitor": "^3.7",
"spatie/laravel-sluggable": "^3.5",
"sqids/sqids": "^0.4.1",
"xantios/mimey": "^2.2.0",
"spatie/laravel-pdf": "^1.9",
"mossadal/math-parser": "^1.3",
"rlanvin/php-rrule": "^2.4"
"xantios/mimey": "^2.2.0"
},
"require-dev": {
"cknow/laravel-money": "^7.2",
Expand Down
166 changes: 166 additions & 0 deletions config/oauth.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
<?php

use Fleetbase\Auth\OAuth\Drivers\AppleDriver;
use Fleetbase\Auth\OAuth\Drivers\GithubDriver;
use Fleetbase\Auth\OAuth\Drivers\GoogleDriver;
use Fleetbase\Auth\OAuth\Drivers\MicrosoftDriver;

/*
|--------------------------------------------------------------------------
| OAuth / OIDC sign-in
|--------------------------------------------------------------------------
|
| Fleetbase resolves every value here from the database first (the admin
| settings UI writes `system.oauth`) and falls back to these env-backed
| defaults. That ordering is what lets a self-hosted operator configure
| providers entirely through the environment, while a Fleetbase Cloud admin
| configures them through the console.
|
| Secrets set through the admin UI are stored encrypted. Secrets set through
| the environment are read as-is — the environment is already trusted.
|
| These values are read exclusively through OAuthConfigRepository, never with
| a bare env() call: `config:cache` is in active use, and env() returns null
| under a cached config.
|
*/

return [
/*
|--------------------------------------------------------------------------
| Global switches
|--------------------------------------------------------------------------
|
| `enabled` is the kill switch for the whole feature. `allow_registration`
| separately controls whether an unrecognised provider identity may start a
| Fleetbase signup, so an operator can offer OAuth sign-in to existing users
| without opening self-service registration.
|
*/
'enabled' => env('OAUTH_ENABLED', true),
'allow_registration' => env('OAUTH_ALLOW_REGISTRATION', true),

/*
|--------------------------------------------------------------------------
| Automatic linking
|--------------------------------------------------------------------------
|
| When a provider identity is not linked yet but its verified email matches
| exactly one existing console account (type `admin` or `user`) whose own
| email is confirmed, link it and sign them in instead of asking them to
| sign in some other way and link it by hand. Two-factor still applies, and
| the account holder is emailed. See OAuthController::autoLinkCandidate().
|
*/
'auto_link' => env('OAUTH_AUTO_LINK', true),

/*
|--------------------------------------------------------------------------
| Console landing path
|--------------------------------------------------------------------------
|
| Where the callback sends the browser once the provider handshake is done.
| The host is always taken from the console configuration — never from the
| request — so this is a path, not a URL.
|
*/
'console_callback_path' => env('OAUTH_CONSOLE_CALLBACK_PATH', '/auth/oauth/callback'),

/*
|--------------------------------------------------------------------------
| Redirect base
|--------------------------------------------------------------------------
|
| The public origin of this API, used to build the redirect_uri handed to
| providers. Defaults to app.url. It must match what is registered in each
| provider's console byte for byte.
|
*/
'redirect_base' => env('OAUTH_REDIRECT_BASE'),

/*
|--------------------------------------------------------------------------
| Strict IP binding
|--------------------------------------------------------------------------
|
| Off by default: a phone that moves between wifi and cellular mid-flow
| legitimately changes address, and failing those users closed costs more
| than this binding is worth. Operators who can guarantee stable addressing
| can turn it into a hard failure.
|
*/
'strict_ip_binding' => env('OAUTH_STRICT_IP_BINDING', false),

/*
|--------------------------------------------------------------------------
| Token lifetimes (seconds)
|--------------------------------------------------------------------------
|
| authorization — the provider round trip. Generous: a user may have to
| complete MFA at the provider.
| handoff — callback to console exchange. Deliberately tight; the
| browser redeems it immediately.
| registration_intent — how long a verified identity may sit unused while
| the user fills in the signup wizard.
|
*/
'ttl' => [
'authorization' => (int) env('OAUTH_TTL_AUTHORIZATION', 600),
'handoff' => (int) env('OAUTH_TTL_HANDOFF', 120),
'registration_intent' => (int) env('OAUTH_TTL_REGISTRATION_INTENT', 900),
],

/*
|--------------------------------------------------------------------------
| Providers
|--------------------------------------------------------------------------
|
| Adding a provider later means: one class under Auth/OAuth/Drivers, one
| Socialite subclass if Socialite core does not ship the protocol, and one
| entry here. No route, controller, migration or console change — the
| registry, the {provider} route parameter and the admin UI schema are all
| driven off this map and the driver's configSchema().
|
*/
'providers' => [
'google' => [
'driver' => GoogleDriver::class,
'enabled' => env('OAUTH_GOOGLE_ENABLED', false),
'client_id' => env('OAUTH_GOOGLE_CLIENT_ID'),
'client_secret' => env('OAUTH_GOOGLE_CLIENT_SECRET'),
// Restrict sign-in to a Google Workspace domain. Enforced server side
// against the verified `hd` claim, not just sent as a request hint.
'hosted_domain' => env('OAUTH_GOOGLE_HOSTED_DOMAIN'),
],

'microsoft' => [
'driver' => MicrosoftDriver::class,
'enabled' => env('OAUTH_MICROSOFT_ENABLED', false),
'client_id' => env('OAUTH_MICROSOFT_CLIENT_ID'),
'client_secret' => env('OAUTH_MICROSOFT_CLIENT_SECRET'),
// 'common' accepts both work/school and personal accounts. A tenant
// id or domain restricts sign-in to that tenant — and is what makes
// the provider's email assertion trustworthy. See MicrosoftDriver.
'tenant' => env('OAUTH_MICROSOFT_TENANT', 'common'),
],

'github' => [
'driver' => GithubDriver::class,
'enabled' => env('OAUTH_GITHUB_ENABLED', false),
'client_id' => env('OAUTH_GITHUB_CLIENT_ID'),
'client_secret' => env('OAUTH_GITHUB_CLIENT_SECRET'),
],

'apple' => [
'driver' => AppleDriver::class,
'enabled' => env('OAUTH_APPLE_ENABLED', false),
// The Services ID, e.g. io.fleetbase.console — not the app bundle id.
'client_id' => env('OAUTH_APPLE_CLIENT_ID'),
'team_id' => env('OAUTH_APPLE_TEAM_ID'),
'key_id' => env('OAUTH_APPLE_KEY_ID'),
// The contents of the .p8 signing key. Apple has no static client
// secret; one is minted as a short-lived ES256 JWT from this key.
'private_key' => env('OAUTH_APPLE_PRIVATE_KEY'),
],
],
];
56 changes: 56 additions & 0 deletions migrations/2026_09_18_000001_create_oauth_identities_table.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
/**
* Run the migrations.
*
* Stores the link between a Fleetbase user and an external OAuth/OIDC identity.
*
* Authentication keys on (provider, provider_user_id) — never on email — so the unique
* index below is a security control, not just a data-integrity one: it is what guarantees
* a provider subject can never be claimed by two Fleetbase accounts.
*
* Deliberately NOT soft-deleting. MySQL unique indexes include soft-deleted rows, so a
* soft-deleted identity would permanently block re-linking that same provider account —
* which is exactly what a user does right after an accidental unlink. Unlinks are audited
* through spatie/laravel-activitylog instead.
*/
public function up(): void
{
Schema::create('oauth_identities', function (Blueprint $table) {
$table->increments('id');
$table->uuid('uuid')->nullable()->index();
$table->foreignUuid('user_uuid')->references('uuid')->on('users')->onUpdate('CASCADE')->onDelete('CASCADE');

$table->string('provider', 40);
// 191 keeps the composite unique index inside the utf8mb4 767-byte index limit.
$table->string('provider_user_id', 191);

// The address the provider reported at link time. Never authoritative for lookup;
// kept so an admin can see which account an identity belongs to.
$table->string('provider_email')->nullable();
// Whether the provider ASSERTED the address as verified. Defaults false: an
// unknown verification state must never be recorded as verified.
$table->boolean('email_verified')->default(false);

$table->json('meta')->nullable();
$table->timestamp('last_login_at')->nullable();
$table->timestamps();

$table->unique(['provider', 'provider_user_id'], 'oauth_identities_provider_subject_unique');
$table->index(['user_uuid', 'provider']);
});
}

/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('oauth_identities');
}
};
Loading
Loading