Skip to content
Open
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
235 changes: 235 additions & 0 deletions src/Components/Security/AdminAccountService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,235 @@
<?php

declare(strict_types=1);

namespace Frosh\Tools\Components\Security;

use Doctrine\DBAL\Connection;

/**
* Reads all accounts that can authenticate against the Admin API: admin users,
* integrations and their ACL roles.
*
* Exposes ids and security relevant attributes only - no usernames, real names
* or access keys. Aggregated across many shops those would form a credential
* stuffing list, so do not add them. E-mail addresses are masked to a fixed
* width by {@see maskEmail()} and must stay that way.
*/
class AdminAccountService
{
private const ADMIN_USER_PATH = '/admin#/sw/users/permissions/user/detail/';
private const ADMIN_INTEGRATION_PATH = '/admin#/sw/integration/index';

public function __construct(private readonly Connection $connection)
{
}

/**
* @return list<array<string, mixed>>
*/
public function getUsers(): array
{
$rows = $this->connection->fetchAllAssociative(
'SELECT LOWER(HEX(`u`.`id`)) AS `id`,
`u`.`email`,
`u`.`active`,
`u`.`admin`,
`u`.`created_at`,
`u`.`updated_at`,
`u`.`last_updated_password_at`,
COUNT(DISTINCT `k`.`id`) AS `access_key_count`,
MAX(`k`.`last_usage_at`) AS `access_key_last_usage_at`,
`r`.`user_id` IS NOT NULL AS `password_recovery_pending`
FROM `user` AS `u`
LEFT JOIN `user_access_key` AS `k` ON `k`.`user_id` = `u`.`id`
LEFT JOIN `user_recovery` AS `r` ON `r`.`user_id` = `u`.`id`
GROUP BY `u`.`id`, `r`.`user_id`
ORDER BY `u`.`created_at`',
);

$roles = $this->fetchRoleAssignments(
'SELECT LOWER(HEX(`user_id`)) AS `owner_id`, LOWER(HEX(`acl_role_id`)) AS `role_id` FROM `acl_user_role`',
);

$users = [];
foreach ($rows as $row) {
$id = (string) $row['id'];

$users[] = [
'id' => $id,
'maskedEmail' => $this->maskEmail($row['email']),
'active' => (bool) $row['active'],
'admin' => (bool) $row['admin'],
'aclRoleIds' => $roles[$id] ?? [],
'accessKeyCount' => (int) $row['access_key_count'],
'accessKeyLastUsageAt' => $this->formatDate($row['access_key_last_usage_at']),
'passwordRecoveryPending' => (bool) $row['password_recovery_pending'],
'lastUpdatedPasswordAt' => $this->formatDate($row['last_updated_password_at']),
'createdAt' => $this->formatDate($row['created_at']),
'updatedAt' => $this->formatDate($row['updated_at']),
'adminPath' => self::ADMIN_USER_PATH . $id,
];
}

return $users;
}

/**
* @return list<array<string, mixed>>
*/
public function getIntegrations(): array
{
$rows = $this->connection->fetchAllAssociative(
'SELECT LOWER(HEX(`i`.`id`)) AS `id`,
`i`.`label`,
`i`.`admin`,
`i`.`last_usage_at`,
`i`.`deleted_at`,
`i`.`created_at`,
`i`.`updated_at`,
`a`.`name` AS `app_name`,
LOWER(HEX(`a`.`acl_role_id`)) AS `app_acl_role_id`
FROM `integration` AS `i`
LEFT JOIN `app` AS `a` ON `a`.`integration_id` = `i`.`id`
ORDER BY `i`.`created_at`',
);

$roles = $this->fetchRoleAssignments(
'SELECT LOWER(HEX(`integration_id`)) AS `owner_id`, LOWER(HEX(`acl_role_id`)) AS `role_id` FROM `integration_role`',
);

$integrations = [];
foreach ($rows as $row) {
$id = (string) $row['id'];
$appName = $row['app_name'] !== null ? (string) $row['app_name'] : null;

// An app grants its integration permissions through `app.acl_role_id`,
// not through `integration_role`, and that path even takes precedence.
$roleIds = $roles[$id] ?? [];
if ($row['app_acl_role_id'] !== null) {
$roleIds[] = (string) $row['app_acl_role_id'];
$roleIds = array_values(array_unique($roleIds));
sort($roleIds);
}

$integrations[] = [
'id' => $id,
'label' => (string) $row['label'],
'admin' => (bool) $row['admin'],
'aclRoleIds' => $roleIds,
// App-owned integrations come and go with the app, no manual credentials.
'managedByApp' => $appName !== null,
'appName' => $appName,
'lastUsageAt' => $this->formatDate($row['last_usage_at']),
'deletedAt' => $this->formatDate($row['deleted_at']),
'createdAt' => $this->formatDate($row['created_at']),
'updatedAt' => $this->formatDate($row['updated_at']),
'adminPath' => self::ADMIN_INTEGRATION_PATH,
];
}

return $integrations;
}

/**
* Hashed so a role gaining privileges stays detectable without shipping the
* full list on every poll.
*
* @return list<array<string, mixed>>
*/
public function getAclRoles(): array
{
$rows = $this->connection->fetchAllAssociative(
'SELECT LOWER(HEX(`id`)) AS `id`, `name`, `privileges`, `deleted_at`, `created_at`, `updated_at`
FROM `acl_role`
ORDER BY `created_at`',
);

$roles = [];
foreach ($rows as $row) {
$privileges = $this->decodePrivileges($row['privileges']);
sort($privileges);

$roles[] = [
'id' => (string) $row['id'],
'name' => (string) $row['name'],
'privilegeCount' => \count($privileges),
'privilegesHash' => hash('sha256', implode("\n", $privileges)),
'deletedAt' => $this->formatDate($row['deleted_at']),
'createdAt' => $this->formatDate($row['created_at']),
'updatedAt' => $this->formatDate($row['updated_at']),
];
}

return $roles;
}

/**
* @return array<string, list<string>>
*/
private function fetchRoleAssignments(string $sql): array
{
$assignments = [];
foreach ($this->connection->fetchAllAssociative($sql) as $row) {
$assignments[(string) $row['owner_id']][] = (string) $row['role_id'];
}

foreach ($assignments as &$roleIds) {
sort($roleIds);
}

return $assignments;
}

/**
* @return list<string>
*/
private function decodePrivileges(mixed $value): array
{
if (!\is_string($value) || $value === '') {
return [];
}

try {
$decoded = json_decode($value, true, 512, \JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return [];
}

if (!\is_array($decoded)) {
return [];
}

return array_values(array_map(strval(...), $decoded));
}

private function maskEmail(mixed $value): ?string
{
if (!\is_string($value) || $value === '') {
return null;
}

$at = strrpos($value, '@');
if ($at === false || $at === 0 || $at === \strlen($value) - 1) {
return null;
}

$domain = substr($value, $at + 1);
$dot = strrpos($domain, '.');

return $value[0] . '***@' . $domain[0] . '***' . ($dot !== false ? substr($domain, $dot) : '');
}

private function formatDate(mixed $value): ?string
{
if (!\is_string($value) || $value === '') {
return null;
}

try {
return (new \DateTimeImmutable($value, new \DateTimeZone('UTC')))->format(\DATE_ATOM);
} catch (\Exception) {
return null;
}
}
}
37 changes: 37 additions & 0 deletions src/Controller/SecurityController.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@

use Frosh\Tools\Acl\FroshToolsPrivileges;
use Frosh\Tools\Components\Sbom\CycloneDxSbomGenerator;
use Frosh\Tools\Components\Security\AdminAccountService;
use Frosh\Tools\Components\Security\Checker\SecurityCheckerInterface;
use Frosh\Tools\Components\Security\SecurityCollection;
use Psr\Clock\ClockInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
Expand All @@ -28,6 +30,8 @@ public function __construct(
#[Autowire(param: 'frosh_tools.checker.disabled_checks')]
private readonly array $ignoredChecks,
private readonly CycloneDxSbomGenerator $sbomGenerator,
private readonly AdminAccountService $adminAccountService,
private readonly ClockInterface $clock,
) {
}

Expand All @@ -48,6 +52,39 @@ public function status(): JsonResponse
]);
}

/**
* Inventory of everything that can authenticate against the Admin API, for
* external monitoring to diff against the previous snapshot and alert on new
* accounts or privilege escalation.
*
* Contains no personal data by design - see {@see AdminAccountService}.
*/
#[Route(path: '/security/accounts', name: 'api.frosh.tools.security.accounts', methods: ['GET'])]
public function accounts(): JsonResponse
{
$users = $this->adminAccountService->getUsers();
$integrations = $this->adminAccountService->getIntegrations();
$aclRoles = $this->adminAccountService->getAclRoles();

$activeIntegrations = array_filter($integrations, static fn (array $i): bool => $i['deletedAt'] === null);

return new JsonResponse([
'generatedAt' => $this->clock->now()->format(\DATE_ATOM),
'summary' => [
'users' => \count($users),
'adminUsers' => \count(array_filter($users, static fn (array $u): bool => $u['admin'] === true)),
'activeUsers' => \count(array_filter($users, static fn (array $u): bool => $u['active'] === true)),
'integrations' => \count($activeIntegrations),
'adminIntegrations' => \count(array_filter($activeIntegrations, static fn (array $i): bool => $i['admin'] === true)),
'manualIntegrations' => \count(array_filter($activeIntegrations, static fn (array $i): bool => $i['managedByApp'] === false)),
'aclRoles' => \count($aclRoles),
],
'users' => $users,
'integrations' => $integrations,
'aclRoles' => $aclRoles,
]);
}

#[Route(path: '/security/sbom', name: 'api.frosh.tools.security.sbom', methods: ['GET'])]
public function sbom(Request $request): Response
{
Expand Down
Loading
Loading