Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
4cc401e
fix(2fa): start two-factor sessions only after the password is checked
roncodes Sep 26, 2026
68b1938
fix(push): send FCM order notifications with android high priority
roncodes Sep 26, 2026
17462b1
fix(notifications): resolve notification settings from the notified c…
roncodes Sep 26, 2026
a496779
fix(iam): enforce the change-password permission and let invited user…
roncodes Sep 26, 2026
5ded447
feat: record app and APNs environment on user devices
roncodes Sep 26, 2026
969242f
feat(2fa): sign in with an authenticator app, with recovery codes
roncodes Sep 27, 2026
fb3d405
fix(iam): close authorization gaps in core controllers
roncodes Sep 27, 2026
da90430
chore(release): v1.6.65
roncodes Sep 28, 2026
132e6ba
Merge pull request #272 from fleetbase/fix/2fa-login-hardening
roncodes Sep 28, 2026
2dd7749
Merge pull request #277 from fleetbase/feat/authenticator-app-2fa
roncodes Sep 28, 2026
e6e3788
Merge pull request #274 from fleetbase/fix/notification-settings-queue
roncodes Sep 28, 2026
f7f04dc
Merge branch 'release/v1.6.65' into fix/change-password-permission
roncodes Sep 28, 2026
39ded39
Merge pull request #275 from fleetbase/fix/change-password-permission
roncodes Sep 28, 2026
3a736fe
Merge pull request #273 from fleetbase/fix/push-android-high-priority
roncodes Sep 28, 2026
3f7bb9f
Merge pull request #276 from fleetbase/feat/user-device-push-metadata
roncodes Sep 28, 2026
b2e195e
Merge branch 'release/v1.6.65' into fix/permission-enforcement
roncodes Sep 28, 2026
0a084e1
Merge pull request #278 from fleetbase/fix/permission-enforcement
roncodes Sep 28, 2026
c8296aa
feat: enhance admin organization queries and usage summaries
roncodes Sep 28, 2026
ad39f60
fix: restore core authentication CI coverage
roncodes Sep 28, 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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ jobs:
run: composer test:unit

- name: Generate Coverage Baseline
timeout-minutes: 30
env:
COMPOSER_PROCESS_TIMEOUT: '0'
run: composer coverage:baseline

- name: Upload Coverage Baseline
Expand Down
47 changes: 29 additions & 18 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -1,31 +1,42 @@
# v1.6.64 — Order reporting and test SMS with the entered credentials
# v1.6.65 — Authenticator-app 2FA, sign-in hardening and IAM permission fixes

## Improvements for reporting
## Improvements

- Declare row-level expression columns with `Column::expression($name, $sql, $type)`. Bare names resolve against the table or relationship that declares it, so `JSON_EXTRACT(meta, '$.quantity')` on `payload.entities` reads the joined entity's `meta`. An expression column can be selected, filtered, sorted, grouped by and aggregated.
- Summary columns (`Column::count/sum/avg`) are flagged `aggregate` and resolve to their computation. Without grouping they return a single summary row; with grouping they sit beside the group keys.
- `Table::softDeletes()` and `Relationship::softDeletes()` leave out soft-deleted rows. On joins the filter goes in the `ON` clause, so LEFT joins keep the parent row.
- Computed columns can be group keys, conditions and sort columns, and a grouped report can be sorted by an aggregate's alias. A new `count_distinct` aggregate is available.
- Custom expressions accept the JSON functions, `DATE()`, `CAST(… AS DECIMAL(15,2))` and the other cast types, `DISTINCT`, `IN`, `GROUP_CONCAT(… ORDER BY … SEPARATOR …)`, `->`/`->>` and `INTERVAL n UNIT`.
- `public_id` and `internal_id` are no longer hidden as foreign keys, so ID columns appear in the column picker.
- Relationship columns are labelled with the whole relationship name ("Order Config Namespace", not "Order Namespace"), and aggregate labels use the column label ("Sum (Quantity)").
- `_key` and `_import_id` are never listed or selectable, whatever a schema declares.
- Organization administration supports company and owner identity search, country/timezone/owner-IP and registration/update-date filters, and sorting by current user count. Admins can open organizations outside their own memberships, inspect organization-scoped usage totals, and see members' 2FA methods and linked OAuth providers without exposing authentication secrets.
- Sign in with an authenticator app (TOTP, RFC 6238), next to email and SMS 2FA (#163). It works with Authy, Google Authenticator, Microsoft Authenticator and 1Password.
- New `users/two-fa/authenticator` endpoints to set up, confirm, disable and inspect the app. Setup, disable and regenerating recovery codes need the current password.
- Confirming the app returns 8 single-use recovery codes. `two-fa/verify` accepts an app code (±1 step for clock drift, each code once) or a recovery code.
- `two-fa/resend` falls back to an emailed (or SMS) code and returns the new `method`.
- The secret is encrypted with the app key, and recovery codes are stored as keyed hashes.
- New `Fleetbase\Support\Barcode` helper with a compact `qrCodeSvg()`.
- Record the app, APNs environment and last-seen time on user devices. New nullable `user_devices` columns: `app_identifier` (indexed), `environment` and `last_seen_at`.
- New organization setting to let users change their own password (default on), at `GET/POST companies/auth-settings`. `GET users/password-policy` returns `{can_change_password}`.

## Fixes

- Test SMS Provider and Test Twilio in Admin › System Config › Services use the credentials entered in the form (fleetbase/fleetbase#680). Under Octane the Twilio client was built once per worker, so a test failed with "Credentials are required to create a Client" or reported success for the saved account. The endpoints now rebuild the client from the request's config and release it after the send.
- Settings → Notifications applies when notifications are sent from the queue or a console command (#262). `NotificationRegistry` takes the company from the notification's subject instead of the session. `notifyUsingDefinitionName()` reads the company-scoped settings instead of a global key nothing writes. New `Setting::lookupForCompany()`.
- Invited users can set their first password, and non-admins can change their own (#263). The password endpoints no longer fall through to `iam create user`.
- FCM order notifications are sent with Android `priority: high`, so drivers' phones in Doze get them immediately (#268).

## Security

- Every computed column is validated up front, including in grouped reports. Names must be safe identifiers, and `SELECT` is forbidden.
- Schema-declared columns always take their SQL from the registry, never from the request. Group keys, aggregate columns, sort columns and condition fields must be allowed or computed columns.
- Sort direction is normalised to `asc`/`desc`, and grouped reports validate `aggregateBy.computation`.
- A 2FA session starts only after the password is checked. `GET two-fa/check` used to start one from the identity alone, so an email plus the emailed code was enough to sign in. It now always returns `{twoFaSession: null, isTwoFaEnabled: false}` and no longer reveals whether an account uses 2FA.
- 2FA sessions expire after 10 minutes; they used to live about 56 years. The 5th wrong code deletes the session, and resending doesn't reset the count. Codes are compared in constant time and generated with `random_int`.
- `users/change-password` requires `current_password` in the same request, and `iam change-password` is enforced. `users/set-password` works only once, within 24 hours of accepting an invite. `validate-password` and `change-password` are limited to 10 requests per minute.
- Close authorization gaps where `AuthorizationGuard` resolved to permission names that don't exist:
- Updating the organization and its 2FA policy is limited to the owner, the Administrator role and system admins. Non-admin updates ignore `owner_uuid`, Stripe ids, `plan`, `status`, `trial_ends_at` and `type`.
- `POST two-fa/config` (system 2FA policy) and admin platform metrics are limited to system admins.
- IAM and developer metrics need `iam list user` / `developers list api-key`.
- Reports use the `iam` service: `iam execute report` for direct queries, `iam export report` for exports.
- API credentials, webhooks, API events and request logs check the `api-key`, `webhook`, `event` and `log` permissions.
- Password, auth-setting and authenticator changes are written to the `auth` activity log.

## Behaviour changes

- In a grouped report, a selected column that is neither a group key nor aggregated is now an error instead of being dropped.
- Invalid report shapes fail with a clear message instead of an SQL error.
- Consoles need the companion fleetbase/fleetbase changes: the 2FA sign-in flow (`fix/2fa-login-hardening`), `current_password` on change-password (fleetbase/fleetbase#685) and the authenticator-app UI (fleetbase/fleetbase#686). With an older console, users with 2FA can't sign in and self-service password changes fail.
- Users need `iam … report` permissions to use reports, and `developers …` permissions for API keys, webhooks, events and logs. Fleet-Ops' report screens check the same names in fleetbase/fleetops#345.
- A user who accepted an invite before this release but never set a password should use **Forgot password**.

A database migration is not required. No configuration change is needed. The FleetOps order report schema ships in fleetbase/fleetops v0.6.70, and the report builder changes in fleetbase/ember-ui v0.4.4.
Run the migrations for the new `user_devices` columns. Run `composer update` to install `pragmarx/google2fa`.

Changes: [#269](https://github.com/fleetbase/core-api/pull/269), [#270](https://github.com/fleetbase/core-api/pull/270).
Changes: [#272](https://github.com/fleetbase/core-api/pull/272), [#273](https://github.com/fleetbase/core-api/pull/273), [#274](https://github.com/fleetbase/core-api/pull/274), [#275](https://github.com/fleetbase/core-api/pull/275), [#276](https://github.com/fleetbase/core-api/pull/276), [#277](https://github.com/fleetbase/core-api/pull/277), [#278](https://github.com/fleetbase/core-api/pull/278).
4 changes: 3 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "fleetbase/core-api",
"version": "1.6.64",
"version": "1.6.65",
"description": "Core Framework and Resources for Fleetbase API",
"keywords": [
"fleetbase",
Expand Down Expand Up @@ -46,9 +46,11 @@
"lcobucci/clock": "3.3.1",
"lcobucci/jwt": "^5.4",
"maatwebsite/excel": "^3.1",
"milon/barcode": "^10.0",
"mossadal/math-parser": "^1.3",
"phpoffice/phpspreadsheet": "^1.28",
"phrity/websocket": "^1.7",
"pragmarx/google2fa": "^8.0",
"rlanvin/php-rrule": "^2.4",
"sentry/sentry-laravel": "*",
"spatie/laravel-activitylog": "^4.7",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

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

return new class extends Migration {
/**
* Record which app a push token was registered from and which APNs
* environment issued it, so push senders can pick the right credentials
* instead of guessing: `app_identifier` is the uuid of the storefront (or
* other app owner) the device registered through, `environment` is the
* APNs environment (production or sandbox) of an iOS token, and
* `last_seen_at` is when the device last re-registered its token.
*
* Every column is guarded so the migration is safe to run against a
* table that already has some of them.
*/
public function up(): void
{
Schema::table('user_devices', function (Blueprint $table) {
if (!Schema::hasColumn('user_devices', 'app_identifier')) {
$table->string('app_identifier', 191)->nullable()->index()->after('platform');
}

if (!Schema::hasColumn('user_devices', 'environment')) {
$table->string('environment', 32)->nullable()->after('app_identifier');
}

if (!Schema::hasColumn('user_devices', 'last_seen_at')) {
$table->timestamp('last_seen_at')->nullable()->after('status');
}
});
}

public function down(): void
{
Schema::table('user_devices', function (Blueprint $table) {
foreach (['app_identifier', 'environment', 'last_seen_at'] as $column) {
if (Schema::hasColumn('user_devices', $column)) {
$table->dropColumn($column);
}
}
});
}
};
13 changes: 13 additions & 0 deletions src/Http/Controllers/Internal/v1/AdminMetricsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
use Fleetbase\Models\Activity;
use Fleetbase\Models\Company;
use Fleetbase\Models\User;
use Fleetbase\Support\Auth;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
Expand All @@ -14,6 +15,18 @@

class AdminMetricsController extends Controller
{
public function __construct()
{
// Platform-wide figures: system administrators only.
$this->middleware(function ($request, $next) {
if (!Auth::getUserFromSession($request)?->isAdmin()) {
return response()->error('Only system administrators can view platform metrics.', 401);
}

return $next($request);
});
}

public function kpi(Request $request, string $slug): JsonResponse
{
[$currentPeriodStart, $previousPeriodStart] = $this->periodBoundaries();
Expand Down
5 changes: 5 additions & 0 deletions src/Http/Controllers/Internal/v1/ApiCredentialController.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ class ApiCredentialController extends FleetbaseController
*/
public $service = 'developers';

/**
* The IAM schema resource this controller's permissions use.
*/
public string $permissionResource = 'api-key';

/**
* Create a new API credential record.
*
Expand Down
5 changes: 5 additions & 0 deletions src/Http/Controllers/Internal/v1/ApiEventController.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,9 @@ class ApiEventController extends FleetbaseController
* @var string
*/
public $service = 'developers';

/**
* The IAM schema resource this controller's permissions use.
*/
public string $permissionResource = 'event';
}
5 changes: 5 additions & 0 deletions src/Http/Controllers/Internal/v1/ApiRequestLogController.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,9 @@ class ApiRequestLogController extends FleetbaseController
* @var string
*/
public $service = 'developers';

/**
* The IAM schema resource this controller's permissions use.
*/
public string $permissionResource = 'log';
}
126 changes: 125 additions & 1 deletion src/Http/Controllers/Internal/v1/CompanyController.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace Fleetbase\Http\Controllers\Internal\v1;

use Fleetbase\Attributes\SkipAuthorizationCheck;
use Fleetbase\Events\UserRemovedFromCompany;
use Fleetbase\Exceptions\FleetbaseRequestValidationException;
use Fleetbase\Exports\CompanyExport;
Expand All @@ -14,8 +15,10 @@
use Fleetbase\Models\CompanyUser;
use Fleetbase\Models\ExtensionInstall;
use Fleetbase\Models\Invite;
use Fleetbase\Models\Setting;
use Fleetbase\Models\User;
use Fleetbase\Support\Auth;
use Fleetbase\Support\OrganizationAdminSummary;
use Fleetbase\Support\TwoFactorAuth;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
Expand All @@ -32,14 +35,34 @@ class CompanyController extends FleetbaseController
*/
public $resource = 'company';

/**
* Company attributes only platform administrators may change through updateRecord().
*/
private const PLATFORM_MANAGED_FIELDS = ['owner_uuid', 'stripe_customer_id', 'stripe_connect_id', 'plan', 'status', 'trial_ends_at', 'type'];

public function __construct()
{
parent::__construct();

// Organization settings and the organization's 2FA policy: owner, Administrator role or system admin.
$this->middleware(function ($request, $next) {
$company = Company::where('uuid', session('company'))->first();
if (!$company || !$this->currentUserManagesOrganization($company)) {
return response()->error('Only the organization owner or an Administrator can change organization settings.', 401);
}

return $next($request);
})->only(['updateRecord', 'saveTwoFactorSettings']);
}

/**
* Find an organization visible to the current session company.
*
* @return \Illuminate\Http\Response|array
*/
public function findRecord(Request $request, $id)
{
$company = $this->resolveVisibleCompany($id);
$company = $this->resolveVisibleCompanyForUsers($id, $request);

if (!$company) {
return response()->error('Organization not found.', 404);
Expand All @@ -63,6 +86,11 @@ public function updateRecord(Request $request, string $id)

try {
$input = $this->model->getApiPayloadFromRequest($request);

// Ownership moves through transferOwnership(); billing and lifecycle fields are platform-managed.
if (!$request->user()?->isAdmin()) {
$input = Arr::except($input, self::PLATFORM_MANAGED_FIELDS);
}
$input = $this->model->fillSessionAttributes($input, [], ['updated_by_uuid']);

if ($this->model->isColumn('slug')) {
Expand Down Expand Up @@ -157,6 +185,7 @@ public function saveTwoFactorSettings(Request $request)
if (!$company) {
return response()->error('No company session found', 401);
}

if (isset($twoFaSettings['enabled']) && $twoFaSettings['enabled'] === false) {
$twoFaSettings['enforced'] = false;
}
Expand All @@ -165,6 +194,63 @@ public function saveTwoFactorSettings(Request $request)
return response()->json(['message' => 'Two-Factor Authentication saved successfully']);
}

/**
* Get the current organization's authentication settings.
*
* @return \Illuminate\Http\Response
*/
#[SkipAuthorizationCheck]
public function getAuthSettings()
{
$company = Auth::getCompany();

if (!$company) {
return response()->error('No company session found', 401);
}

return response()->json(Auth::getCompanyAuthSettings($company->uuid));
}

/**
* Save the current organization's authentication settings. Only admins and users
* holding the Administrator role may change them.
*
* @return \Illuminate\Http\Response
*/
#[SkipAuthorizationCheck]
public function saveAuthSettings(Request $request)
{
$user = $request->user();
$company = Auth::getCompany();

if (!$company) {
return response()->error('No company session found', 401);
}

if (!$user || !($user->isAdmin() || $user->hasRole('Administrator'))) {
return response()->error('Only administrators can change authentication settings.', 403);
}

if (!$request->has('allow_users_change_password')) {
return response()->error('No authentication settings provided.', 422);
}

$settings = array_merge(Auth::getCompanyAuthSettings($company->uuid), [
'allow_users_change_password' => $request->boolean('allow_users_change_password'),
]);

Setting::configure('company.' . $company->uuid . '.auth', $settings);

activity('auth')
->causedBy($user)
->performedOn($company)
->withProperties($settings)
->event('auth_settings_updated')
->log('Authentication settings updated');

return response()->json($settings);
}

/**
* Get all users for a company.
*
Expand Down Expand Up @@ -213,6 +299,10 @@ public function users(string $id, Request $request)
// replace in pagination
$users->setCollection($transformedItems);

if ($request->user()?->isAdmin()) {
OrganizationAdminSummary::attachAuthentication($transformedItems);
}

return response()->json([
'users' => UserResource::collection($users->getCollection()),
'meta' => [
Expand All @@ -237,9 +327,23 @@ public function users(string $id, Request $request)
return $companyUser->user;
});

if ($request->user()?->isAdmin()) {
OrganizationAdminSummary::attachAuthentication($users);
}

return UserResource::collection($users);
}

public function usage(string $id, AdminRequest $request): JsonResponse
{
$company = $this->resolveAdminCompany($id);
if (!$company) {
return response()->json(['error' => 'Organization not found.'], 404);
}

return response()->json(['usage' => OrganizationAdminSummary::usage($company)]);
}

private function resolveVisibleCompanyForUsers(string $id, Request $request): ?Company
{
$user = $request->user();
Expand All @@ -261,6 +365,26 @@ private function resolveVisibleCompanyForUsers(string $id, Request $request): ?C
->first();
}

/**
* Whether the session user may manage the organization: platform admins, the owner,
* and members holding the Administrator role in it.
*/
private function currentUserManagesOrganization(Company $company): bool
{
$user = Auth::getUserFromSession();
if (!$user) {
return false;
}

if ($user->isAdmin() || $company->owner_uuid === $user->uuid) {
return true;
}

$companyUser = CompanyUser::where('company_uuid', $company->uuid)->where('user_uuid', $user->uuid)->first();

return $companyUser !== null && $companyUser->roles()->where('name', 'Administrator')->exists();
}

private function resolveVisibleCompany(string $id): ?Company
{
$sessionCompany = session('company');
Expand Down
Loading
Loading