Initial Version

This commit is contained in:
root
2025-12-21 10:09:54 -05:00
commit 2fbddd7dbc
366 changed files with 41999 additions and 0 deletions

View File

@@ -0,0 +1,224 @@
<?php
declare(strict_types=1);
namespace KTXC\Stores;
use KTXC\Db\DataStore;
/**
* External Identity Store
* Maps external identity provider subjects to local users
*
* Collection: external_identities
* Schema: {
* tid: string, // Tenant identifier
* uid: string, // Local user identifier
* provider: string, // Provider identifier (e.g., 'oidc', 'saml')
* external_subject: string, // External subject identifier (e.g., OIDC sub, SAML NameID)
* attributes: object, // Cached attributes from provider
* linked_at: int, // Timestamp when identity was linked
* last_login: int // Last login via this external identity
* }
*/
class ExternalIdentityStore
{
protected const COLLECTION_NAME = 'external_identities';
public function __construct(protected DataStore $store)
{ }
/**
* Find external identity by provider and external subject
*
* @param string $tenant Tenant identifier
* @param string $provider Provider identifier
* @param string $externalSubject External subject identifier
* @return array|null External identity record or null
*/
public function findByExternalSubject(string $tenant, string $provider, string $externalSubject): ?array
{
$entry = $this->store->selectCollection(self::COLLECTION_NAME)->findOne([
'tid' => $tenant,
'provider' => $provider,
'external_subject' => $externalSubject
]);
if (!$entry) {
return null;
}
return (array)$entry;
}
/**
* Find all external identities for a user
*
* @param string $tenant Tenant identifier
* @param string $userId Local user identifier
* @return array<array> List of external identity records
*/
public function findByUser(string $tenant, string $userId): array
{
$cursor = $this->store->selectCollection(self::COLLECTION_NAME)->find([
'tid' => $tenant,
'uid' => $userId
]);
$result = [];
foreach ($cursor as $entry) {
$result[] = (array)$entry;
}
return $result;
}
/**
* Find external identity for a user from a specific provider
*
* @param string $tenant Tenant identifier
* @param string $userId Local user identifier
* @param string $provider Provider identifier
* @return array|null External identity record or null
*/
public function findByUserAndProvider(string $tenant, string $userId, string $provider): ?array
{
$entry = $this->store->selectCollection(self::COLLECTION_NAME)->findOne([
'tid' => $tenant,
'uid' => $userId,
'provider' => $provider
]);
if (!$entry) {
return null;
}
return (array)$entry;
}
/**
* Link an external identity to a local user
*
* @param string $tenant Tenant identifier
* @param string $userId Local user identifier
* @param string $provider Provider identifier
* @param string $externalSubject External subject identifier
* @param array $attributes Optional attributes from provider
* @return bool Whether the operation was successful
*/
public function linkIdentity(
string $tenant,
string $userId,
string $provider,
string $externalSubject,
array $attributes = []
): bool {
$now = time();
// Use upsert to handle both create and update
$result = $this->store->selectCollection(self::COLLECTION_NAME)->updateOne(
[
'tid' => $tenant,
'provider' => $provider,
'external_subject' => $externalSubject
],
[
'$set' => [
'uid' => $userId,
'attributes' => $attributes,
'last_login' => $now
],
'$setOnInsert' => [
'tid' => $tenant,
'provider' => $provider,
'external_subject' => $externalSubject,
'linked_at' => $now
]
],
['upsert' => true]
);
return $result->isAcknowledged();
}
/**
* Unlink an external identity from a user
*
* @param string $tenant Tenant identifier
* @param string $userId Local user identifier
* @param string $provider Provider identifier
* @return bool Whether the operation was successful
*/
public function unlinkIdentity(string $tenant, string $userId, string $provider): bool
{
$result = $this->store->selectCollection(self::COLLECTION_NAME)->deleteOne([
'tid' => $tenant,
'uid' => $userId,
'provider' => $provider
]);
return $result->isAcknowledged();
}
/**
* Update last login timestamp for an external identity
*
* @param string $tenant Tenant identifier
* @param string $provider Provider identifier
* @param string $externalSubject External subject identifier
* @return bool Whether the operation was successful
*/
public function updateLastLogin(string $tenant, string $provider, string $externalSubject): bool
{
$result = $this->store->selectCollection(self::COLLECTION_NAME)->updateOne(
[
'tid' => $tenant,
'provider' => $provider,
'external_subject' => $externalSubject
],
['$set' => ['last_login' => time()]]
);
return $result->isAcknowledged();
}
/**
* Update cached attributes for an external identity
*
* @param string $tenant Tenant identifier
* @param string $provider Provider identifier
* @param string $externalSubject External subject identifier
* @param array $attributes New attributes to store
* @return bool Whether the operation was successful
*/
public function updateAttributes(string $tenant, string $provider, string $externalSubject, array $attributes): bool
{
$result = $this->store->selectCollection(self::COLLECTION_NAME)->updateOne(
[
'tid' => $tenant,
'provider' => $provider,
'external_subject' => $externalSubject
],
['$set' => ['attributes' => $attributes]]
);
return $result->isAcknowledged();
}
/**
* Delete all external identities for a user (used when deleting user)
*
* @param string $tenant Tenant identifier
* @param string $userId Local user identifier
* @return int Number of deleted records
*/
public function deleteAllForUser(string $tenant, string $userId): int
{
$result = $this->store->selectCollection(self::COLLECTION_NAME)->deleteMany([
'tid' => $tenant,
'uid' => $userId
]);
return $result->getDeletedCount();
}
}

View File

@@ -0,0 +1,309 @@
<?php
declare(strict_types=1);
namespace KTXC\Stores;
use KTXC\Db\DataStore;
use KTXC\Models\Firewall\FirewallRuleObject;
use KTXC\Models\Firewall\FirewallLogObject;
/**
* Store for firewall rules and access logs
*/
class FirewallStore
{
protected const RULES_COLLECTION = 'firewall_rules';
protected const LOGS_COLLECTION = 'firewall_logs';
public function __construct(
protected readonly DataStore $dataStore
) {}
// ========================================
// Rule Operations
// ========================================
/**
* List all rules for a tenant
*/
public function listRules(string $tenantId, bool $activeOnly = true): array
{
$filter = ['tenantId' => $tenantId];
if ($activeOnly) {
$filter['enabled'] = true;
$filter['$or'] = [
['expiresAt' => null],
['expiresAt' => ['$gt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)]]
];
}
$cursor = $this->dataStore->selectCollection(self::RULES_COLLECTION)->find($filter);
$list = [];
foreach ($cursor as $entry) {
$rule = (new FirewallRuleObject())->jsonDeserialize((array)$entry);
$list[] = $rule;
}
return $list;
}
/**
* Find rules by IP address
*/
public function findRulesByIp(string $tenantId, string $ipAddress): array
{
$filter = [
'tenantId' => $tenantId,
'type' => ['$in' => [FirewallRuleObject::TYPE_IP, FirewallRuleObject::TYPE_IP_RANGE]],
'enabled' => true,
'$or' => [
['expiresAt' => null],
['expiresAt' => ['$gt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)]]
]
];
$cursor = $this->dataStore->selectCollection(self::RULES_COLLECTION)->find($filter);
$list = [];
foreach ($cursor as $entry) {
$rule = (new FirewallRuleObject())->jsonDeserialize((array)$entry);
$list[] = $rule;
}
return $list;
}
/**
* Find rules by device fingerprint
*/
public function findRulesByDevice(string $tenantId, string $deviceFingerprint): array
{
$filter = [
'tenantId' => $tenantId,
'type' => FirewallRuleObject::TYPE_DEVICE,
'value' => $deviceFingerprint,
'enabled' => true,
'$or' => [
['expiresAt' => null],
['expiresAt' => ['$gt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)]]
]
];
$cursor = $this->dataStore->selectCollection(self::RULES_COLLECTION)->find($filter);
$list = [];
foreach ($cursor as $entry) {
$rule = (new FirewallRuleObject())->jsonDeserialize((array)$entry);
$list[] = $rule;
}
return $list;
}
/**
* Fetch a specific rule by ID
*/
public function fetchRule(string $id): ?FirewallRuleObject
{
$entry = $this->dataStore->selectCollection(self::RULES_COLLECTION)->findOne(['_id' => $id]);
if (!$entry) {
return null;
}
return (new FirewallRuleObject())->jsonDeserialize((array)$entry);
}
/**
* Check if exact IP rule exists
*/
public function findExactIpRule(string $tenantId, string $ipAddress, string $action): ?FirewallRuleObject
{
$entry = $this->dataStore->selectCollection(self::RULES_COLLECTION)->findOne([
'tenantId' => $tenantId,
'type' => FirewallRuleObject::TYPE_IP,
'value' => $ipAddress,
'action' => $action,
'enabled' => true,
]);
if (!$entry) {
return null;
}
return (new FirewallRuleObject())->jsonDeserialize((array)$entry);
}
/**
* Create or update a rule
*/
public function depositRule(FirewallRuleObject $rule): ?FirewallRuleObject
{
if ($rule->getId()) {
return $this->updateRule($rule);
} else {
return $this->createRule($rule);
}
}
private function createRule(FirewallRuleObject $rule): ?FirewallRuleObject
{
$data = $rule->jsonSerialize();
unset($data['id']); // Remove id for insert
$result = $this->dataStore->selectCollection(self::RULES_COLLECTION)->insertOne($data);
$rule->setId((string)$result->getInsertedId());
return $rule;
}
private function updateRule(FirewallRuleObject $rule): ?FirewallRuleObject
{
$id = $rule->getId();
if (!$id) {
return null;
}
$data = $rule->jsonSerialize();
unset($data['id']);
$this->dataStore->selectCollection(self::RULES_COLLECTION)->updateOne(
['_id' => $id],
['$set' => $data]
);
return $rule;
}
/**
* Delete a rule
*/
public function destroyRule(FirewallRuleObject $rule): void
{
$id = $rule->getId();
if (!$id) {
return;
}
$this->dataStore->selectCollection(self::RULES_COLLECTION)->deleteOne(['_id' => $id]);
}
/**
* Delete expired rules
*/
public function cleanupExpiredRules(): int
{
$result = $this->dataStore->selectCollection(self::RULES_COLLECTION)->deleteMany([
'expiresAt' => ['$lt' => (new \DateTimeImmutable())->format(\DateTimeInterface::ATOM)],
'expiresAt' => ['$ne' => null]
]);
return $result->getDeletedCount();
}
// ========================================
// Log Operations
// ========================================
/**
* Log a firewall event
*/
public function createLog(FirewallLogObject $log): FirewallLogObject
{
$data = $log->jsonSerialize();
unset($data['id']);
$result = $this->dataStore->selectCollection(self::LOGS_COLLECTION)->insertOne($data);
$log->setId((string)$result->getInsertedId());
return $log;
}
/**
* Get logs for a tenant with optional filters
*/
public function listLogs(
string $tenantId,
?string $ipAddress = null,
?string $eventType = null,
?string $result = null,
int $limit = 100,
int $offset = 0
): array {
$filter = ['tenantId' => $tenantId];
if ($ipAddress !== null) {
$filter['ipAddress'] = $ipAddress;
}
if ($eventType !== null) {
$filter['eventType'] = $eventType;
}
if ($result !== null) {
$filter['result'] = $result;
}
$cursor = $this->dataStore->selectCollection(self::LOGS_COLLECTION)->find(
$filter,
[
'sort' => ['timestamp' => -1],
'limit' => $limit,
'skip' => $offset
]
);
$list = [];
foreach ($cursor as $entry) {
$log = (new FirewallLogObject())->jsonDeserialize((array)$entry);
$list[] = $log;
}
return $list;
}
/**
* Count recent failures from an IP within a time window
*/
public function countRecentFailures(
string $tenantId,
string $ipAddress,
int $windowSeconds = 300
): int {
$since = (new \DateTimeImmutable())->modify("-{$windowSeconds} seconds");
return $this->dataStore->selectCollection(self::LOGS_COLLECTION)->countDocuments([
'tenantId' => $tenantId,
'ipAddress' => $ipAddress,
'eventType' => FirewallLogObject::EVENT_AUTH_FAILURE,
'timestamp' => ['$gte' => $since->format(\DateTimeInterface::ATOM)]
]);
}
/**
* Get blocked requests count for dashboard
*/
public function countBlockedRequests(
string $tenantId,
?\DateTimeImmutable $since = null
): int {
$filter = [
'tenantId' => $tenantId,
'result' => FirewallLogObject::RESULT_BLOCKED
];
if ($since !== null) {
$filter['timestamp'] = ['$gte' => $since->format(\DateTimeInterface::ATOM)];
}
return $this->dataStore->selectCollection(self::LOGS_COLLECTION)->countDocuments($filter);
}
/**
* Clean up old logs
*/
public function cleanupOldLogs(int $daysToKeep = 30): int
{
$cutoff = (new \DateTimeImmutable())->modify("-{$daysToKeep} days");
$result = $this->dataStore->selectCollection(self::LOGS_COLLECTION)->deleteMany([
'timestamp' => ['$lt' => $cutoff->format(\DateTimeInterface::ATOM)]
]);
return $result->getDeletedCount();
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace KTXC\Stores;
use KTXC\Db\DataStore;
use KTXC\Models\Tenant\TenantObject;
class TenantStore
{
protected const COLLECTION_NAME = 'tenants';
public function __construct(
protected readonly DataStore $dataStore
) { }
public function list(): array
{
$cursor = $this->dataStore->selectCollection(self::COLLECTION_NAME)->find();
$list = [];
foreach ($cursor as $entry) {
$entry = (new TenantObject())->jsonDeserialize((array)$entry);
$list[$entry->getId()] = $entry;
}
return $list;
}
public function fetch(string $identifier): ?TenantObject
{
$entry = $this->dataStore->selectCollection(self::COLLECTION_NAME)->findOne(['identifier' => $identifier]);
if (!$entry) { return null; }
return (new TenantObject())->jsonDeserialize((array)$entry);
}
public function fetchByDomain(string $domain): ?TenantObject
{
$entry = $this->dataStore->selectCollection(self::COLLECTION_NAME)->findOne(['domains' => $domain]);
if (!$entry) { return null; }
$entity = new TenantObject();
$entity->jsonDeserialize((array)$entry);
return $entity;
}
public function deposit(TenantObject $entry): ?TenantObject
{
if ($entry->getId()) {
return $this->update($entry);
} else {
return $this->create($entry);
}
}
private function create(TenantObject $entry): ?TenantObject
{
$result = $this->dataStore->selectCollection(self::COLLECTION_NAME)->insertOne($entry->jsonSerialize());
$entry->setId((string)$result->getInsertedId());
return $entry;
}
private function update(TenantObject $entry): ?TenantObject
{
$id = $entry->getId();
if (!$id) { return null; }
$this->dataStore->selectCollection(self::COLLECTION_NAME)->updateOne(['_id' => $id], ['$set' => $entry->jsonSerialize()]);
return $entry;
}
public function destroy(TenantObject $entry): void
{
$id = $entry->getId();
if (!$id) { return; }
$this->dataStore->selectCollection(self::COLLECTION_NAME)->deleteOne([ '_id' => $id]);
}
}

View File

@@ -0,0 +1,257 @@
<?php
namespace KTXC\Stores;
use KTXC\Db\DataStore;
use KTXC\Db\Collection;
class UserStore
{
public function __construct(protected DataStore $store)
{ }
public function fetchByIdentity(string $tenant, string $identity): array | null
{
$pipeline = [
[
'$match' => [
'tid' => $tenant,
'identity' => $identity
]
],
[
'$lookup' => [
'from' => 'user_roles',
'localField' => 'roles', // Array field in `users`
'foreignField' => 'rid', // Scalar field in `user_roles`
'as' => 'role_details'
]
],
// Add flattened, deduplicated permissions while preserving all original user fields
[
'$addFields' => [
'permissions' => [
'$reduce' => [
'input' => [
'$map' => [
'input' => '$role_details',
'as' => 'r',
'in' => [ '$ifNull' => ['$$r.permissions', []] ]
]
],
'initialValue' => [],
'in' => [ '$setUnion' => ['$$value', '$$this'] ]
]
]
]
],
// Optionally remove expanded role documents from output
[ '$unset' => 'role_details' ]
];
$entry = $this->store->selectCollection('users')->aggregate($pipeline)->toArray()[0] ?? null;
if (!$entry) { return null; }
return (array)$entry;
}
public function fetchByIdentifier(string $tenant, string $identifier): array | null
{
$entry = $this->store->selectCollection('users')->findOne(['tid' => $tenant, 'uid' => $identifier]);
if (!$entry) { return null; }
return (array)$entry;
}
/**
* Fetch user settings from the embedded settings field in the user document
*
* @param string $tenant Tenant identifier
* @param string $identifier User identifier
* @param array $settings Optional array of specific setting keys to retrieve
* @return array|null Settings array or null if user not found
*/
public function fetchSettings(string $tenant, string $identifier, array $keys = []): array | null
{
$entry = $this->store->selectCollection('users')->findOne(
['tid' => $tenant, 'uid' => $identifier],
['projection' => ['settings' => 1]]
);
if (!$entry) {
return null;
}
$settings = (array)($entry['settings'] ?? []);
if (empty($keys)) {
return $settings;
}
// Filter to only requested keys
return array_filter(
$settings,
fn($key) => in_array($key, $keys),
ARRAY_FILTER_USE_KEY
);
}
/**
* Store/update user settings in the embedded settings field
*
* @param string $tenant Tenant identifier
* @param string $identifier User identifier
* @param array $settings Key-value pairs to set/update
* @return bool Whether the update was acknowledged
*/
public function storeSettings(string $tenant, string $identifier, array $settings): bool
{
// Build dot-notation update for each setting key
$setFields = [];
foreach ($settings as $key => $value) {
$setFields["settings.$key"] = $value;
}
$result = $this->store->selectCollection('users')->updateOne(
['tid' => $tenant, 'uid' => $identifier],
['$set' => $setFields]
);
return $result->isAcknowledged();
}
/**
* Remove specific settings from a user
*
* @param string $tenant Tenant identifier
* @param string $identifier User identifier
* @param array $keys Setting keys to remove
* @return bool Whether the update was acknowledged
*/
public function removeSettings(string $tenant, string $identifier, array $keys): bool
{
$unsetFields = [];
foreach ($keys as $key) {
$unsetFields["settings.$key"] = "";
}
$result = $this->store->selectCollection('users')->updateOne(
['tid' => $tenant, 'uid' => $identifier],
['$unset' => $unsetFields]
);
return $result->isAcknowledged();
}
/**
* Create a new user
*
* @param array $data User data including tid, uid, identity, label, provider, etc.
* @return string|null The created user's UID or null on failure
*/
public function create(array $data): ?string
{
// Ensure required fields
if (empty($data['tid']) || empty($data['uid']) || empty($data['identity'])) {
return null;
}
// Set defaults
$data['enabled'] = $data['enabled'] ?? true;
$data['roles'] = $data['roles'] ?? [];
$data['profile'] = $data['profile'] ?? [];
$data['settings'] = $data['settings'] ?? [];
$data['initial_login'] = $data['initial_login'] ?? time();
$data['recent_login'] = $data['recent_login'] ?? time();
$result = $this->store->selectCollection('users')->insertOne($data);
if ($result->isAcknowledged()) {
return $data['uid'];
}
return null;
}
/**
* Update user profile fields
*
* @param string $tenant Tenant identifier
* @param string $identifier User identifier
* @param array $profile Profile data to update
* @return bool Whether the update was acknowledged
*/
public function updateProfile(string $tenant, string $identifier, array $profile): bool
{
$setFields = [];
foreach ($profile as $key => $value) {
$setFields["profile.$key"] = $value;
}
$result = $this->store->selectCollection('users')->updateOne(
['tid' => $tenant, 'uid' => $identifier],
['$set' => $setFields]
);
return $result->isAcknowledged();
}
/**
* Update user's last login timestamp
*
* @param string $tenant Tenant identifier
* @param string $identifier User identifier
* @return bool Whether the update was acknowledged
*/
public function updateLastLogin(string $tenant, string $identifier): bool
{
$result = $this->store->selectCollection('users')->updateOne(
['tid' => $tenant, 'uid' => $identifier],
['$set' => ['recent_login' => time()]]
);
return $result->isAcknowledged();
}
/**
* Update user's label
*
* @param string $tenant Tenant identifier
* @param string $identifier User identifier
* @param string $label New label
* @return bool Whether the update was acknowledged
*/
public function updateLabel(string $tenant, string $identifier, string $label): bool
{
$result = $this->store->selectCollection('users')->updateOne(
['tid' => $tenant, 'uid' => $identifier],
['$set' => ['label' => $label]]
);
return $result->isAcknowledged();
}
/**
* Find user by external subject (for external identity providers)
*
* @param string $tenant Tenant identifier
* @param string $provider Provider identifier
* @param string $externalSubject External subject identifier
* @return array|null User data or null if not found
*/
public function fetchByExternalSubject(string $tenant, string $provider, string $externalSubject): ?array
{
$entry = $this->store->selectCollection('users')->findOne([
'tid' => $tenant,
'provider' => $provider,
'external_subject' => $externalSubject
]);
if (!$entry) {
return null;
}
return (array)$entry;
}
}