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
128 changes: 125 additions & 3 deletions lib/Controller/RegistersController.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,11 @@
use OCP\DB\Exception as DBException;
use OCP\IUserSession;
use OCA\OpenRegister\Exception\DatabaseConstraintException;
use OCA\OpenRegister\Service\AuthorizationAuditService;
use OCA\OpenRegister\Service\Object\PermissionHandler;
use OCP\IGroupManager;
use OCP\IRequest;
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Component\Uid\Uuid;

Expand Down Expand Up @@ -165,6 +169,8 @@
* @param GitHubHandler $githubService GitHub service for publishing
* @param IAppManager $appManager App manager for app version
* @param OasService $oasService OAS service for OpenAPI generation
* @param ContainerInterface $container Container for lazy loading services
* @param IGroupManager $groupManager Group manager for RBAC checks
*
* @return void
*
Expand All @@ -186,7 +192,9 @@
RegisterMapper $registerMapper,
GitHubHandler $githubService,
IAppManager $appManager,
OasService $oasService
OasService $oasService,
private readonly ContainerInterface $container,
private readonly IGroupManager $groupManager
) {
$this->logger->debug(
message: '[RegistersController] Constructor started.',
Expand Down Expand Up @@ -500,9 +508,57 @@
unset($data['owner']);
unset($data['created']);

// Check manage permission if authorization or roles configuration is being modified.
$oldRegisterAuth = null;
$oldRegisterRoles = null;
if (isset($data['authorization']) === true || isset($data['configuration']['roles']) === true) {
try {
$existingRegister = $this->registerMapper->find($id);
$oldRegisterAuth = $existingRegister->getAuthorization();
$oldConfig = $existingRegister->getConfiguration();
$oldRegisterRoles = $oldConfig['roles'] ?? null;
$manageAllowed = $this->checkRegisterManagePermission(register: $existingRegister);
if ($manageAllowed === false) {
return new JSONResponse(

Check failure on line 522 in lib/Controller/RegistersController.php

View workflow job for this annotation

GitHub Actions / quality / PHP Quality (phpstan)

Method OCA\OpenRegister\Controller\RegistersController::update() should return OCP\AppFramework\Http\JSONResponse<int, array{error: string}, array>|OCP\AppFramework\Http\JSONResponse<200, OCA\OpenRegister\Db\Register, array> but returns OCP\AppFramework\Http\JSONResponse<403, array{error: string}, array{}>.
data: ['error' => 'User does not have permission to manage authorization for this register'],
statusCode: 403
);
}
} catch (DoesNotExistException $e) {
return new JSONResponse(data: ['error' => 'Register not found'], statusCode: 404);

Check failure on line 528 in lib/Controller/RegistersController.php

View workflow job for this annotation

GitHub Actions / quality / PHP Quality (phpstan)

Method OCA\OpenRegister\Controller\RegistersController::update() should return OCP\AppFramework\Http\JSONResponse<int, array{error: string}, array>|OCP\AppFramework\Http\JSONResponse<200, OCA\OpenRegister\Db\Register, array> but returns OCP\AppFramework\Http\JSONResponse<404, array{error: string}, array{}>.
}
}

try {
// Update the register with the provided data.
return new JSONResponse(data: $this->registerService->updateFromArray(id: $id, data: $data));
$updatedRegister = $this->registerService->updateFromArray(id: $id, data: $data);

// Log authorization and role changes.
try {
$auditService = $this->container->get(AuthorizationAuditService::class);

if (isset($data['authorization']) === true) {
$auditService->logRegisterAuthorizationChange(
$id,
$updatedRegister['title'] ?? '',
$oldRegisterAuth,
$updatedRegister['authorization'] ?? null
);
}

if (isset($data['configuration']['roles']) === true) {
$auditService->logRoleDefinitionChange(
$id,
$updatedRegister['title'] ?? '',
$oldRegisterRoles,
$updatedRegister['configuration']['roles'] ?? null
);
}
} catch (\Throwable $e) {
// Audit logging should not break the update operation.
}//end try

return new JSONResponse(data: $updatedRegister);
} catch (DBException $e) {
// Handle database constraint violations with user-friendly messages.
$constraintException = DatabaseConstraintException::fromDatabaseException(
Expand All @@ -519,7 +575,7 @@
data: ['error' => $e->getMessage()],
statusCode: $e->getHttpStatusCode()
);
}
}//end try
}//end update()

/**
Expand Down Expand Up @@ -1431,4 +1487,70 @@
return new JSONResponse(['error' => $e->getMessage()], 400);
}//end try
}//end depublish()

/**
* Check if the current user has 'manage' permission on a register.
*
* Uses the register's own authorization block to check for the 'manage' action.
* Admin users always pass this check.
*
* @param Register $register The register to check manage permission for.
*
* @return bool True if user has manage permission.
*/
private function checkRegisterManagePermission(Register $register): bool
{
$authorization = $register->getAuthorization();

// If no authorization configured, only admins can manage (default secure).
// Check if manage action is defined.
if (empty($authorization) === true || isset($authorization['manage']) === false) {
// Fall back: only admin users can manage if no manage rules are defined.
$user = $this->userSession->getUser();
if ($user === null) {
return false;
}

try {
$groupManager = $this->container->get(\OCP\IGroupManager::class);
$userGroups = $groupManager->getUserGroupIds($user);
return in_array('admin', $userGroups, true);
} catch (\Throwable $e) {
return false;
}
}

// Use PermissionHandler logic via a schema-like check.
// Create a minimal check using the register's authorization directly.
$user = $this->userSession->getUser();
if ($user === null) {
return false;
}

try {
$userGroups = $this->groupManager->getUserGroupIds($user);

// Admin bypass.
if (in_array('admin', $userGroups, true) === true) {
return true;
}

$manageRules = $authorization['manage'];
foreach ($userGroups as $groupId) {
foreach ($manageRules as $entry) {
if (is_string($entry) === true && $entry === $groupId) {
return true;
}

if (is_array($entry) === true && isset($entry['group']) === true && $entry['group'] === $groupId) {
return true;
}
}
}
} catch (\Throwable $e) {
return false;
}//end try

return false;
}//end checkRegisterManagePermission()
}//end class
44 changes: 43 additions & 1 deletion lib/Controller/SchemasController.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@
use OCP\IRequest;
use Symfony\Component\Uid\Uuid;
use OCA\OpenRegister\Db\AuditTrailMapper;
use OCA\OpenRegister\Service\AuthorizationAuditService;
use OCA\OpenRegister\Service\Object\PermissionHandler;
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;

/**
Expand Down Expand Up @@ -91,6 +94,7 @@ class SchemasController extends Controller
* @param FacetCacheHandler $facetCacheSvc Schema facet cache service for facet caching
* @param SchemaService $schemaService Schema service for exploration operations
* @param LoggerInterface $logger Logger for error tracking
* @param ContainerInterface $container Container for lazy loading services
*
* @return void
*
Expand All @@ -109,7 +113,8 @@ public function __construct(
private readonly SchemaCacheHandler $schemaCacheService,
private readonly FacetCacheHandler $facetCacheSvc,
private readonly SchemaService $schemaService,
private readonly LoggerInterface $logger
private readonly LoggerInterface $logger,
private readonly ContainerInterface $container
) {
// Call parent constructor to initialize base controller.
parent::__construct(appName: $appName, request: $request);
Expand Down Expand Up @@ -466,10 +471,47 @@ public function update(int $id): JSONResponse
unset($data['owner']);
unset($data['created']);

// Check manage permission if authorization field is being modified.
$oldSchemaAuth = null;
if (isset($data['authorization']) === true) {
try {
$existingSchema = $this->schemaMapper->find($id);
$oldSchemaAuth = $existingSchema->getAuthorization();
$permissionHandler = $this->container->get(PermissionHandler::class);
if ($permissionHandler->hasPermission(
$existingSchema,
'manage'
) === false
) {
return new JSONResponse(
data: ['error' => 'User does not have permission to manage authorization for this schema'],
statusCode: 403
);
}
} catch (DoesNotExistException $e) {
return new JSONResponse(data: ['error' => 'Schema not found'], statusCode: 404);
}
}

try {
// Update the schema with the provided data.
$updatedSchema = $this->schemaMapper->updateFromArray(id: $id, object: $data);

// Log authorization change if authorization was modified.
if (isset($data['authorization']) === true) {
try {
$auditService = $this->container->get(AuthorizationAuditService::class);
$auditService->logSchemaAuthorizationChange(
$updatedSchema->getId(),
$updatedSchema->getTitle() ?? '',
$oldSchemaAuth,
$updatedSchema->getAuthorization()
);
} catch (\Throwable $e) {
// Audit logging should not break the update operation.
}
}

// **CACHE INVALIDATION**: Clear all schema-related caches when schema is updated.
$this->schemaCacheService->invalidateForSchemaChange(schemaId: $updatedSchema->getId(), operation: 'update');
$this->facetCacheSvc->invalidateForSchemaChange(
Expand Down
36 changes: 32 additions & 4 deletions lib/Db/MagicMapper/MagicRbacHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@
namespace OCA\OpenRegister\Db\MagicMapper;

use DateTime;
use OCA\OpenRegister\Db\RegisterMapper;
use OCA\OpenRegister\Db\Schema;
use OCA\OpenRegister\Service\Object\PermissionHandler;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IUserSession;
use OCP\IGroupManager;
Expand Down Expand Up @@ -131,8 +133,8 @@ public function applyRbacFilters(
return;
}

// Get schema authorization configuration.
$authorization = $schema->getAuthorization();
// Get effective authorization (schema-level, or register cascade).
$authorization = $this->resolveSchemaAuthorization(schema: $schema);

// If no authorization is configured, schema is open to all.
if (empty($authorization) === true) {
Expand Down Expand Up @@ -973,8 +975,8 @@ public function buildRbacConditionsSql(Schema $schema, string $action='read'): a
return ['bypass' => true, 'conditions' => []];
}

// Get schema authorization configuration.
$authorization = $schema->getAuthorization();
// Get effective authorization (schema-level, or register cascade).
$authorization = $this->resolveSchemaAuthorization(schema: $schema);

// If no authorization is configured, schema is open to all.
if (empty($authorization) === true) {
Expand Down Expand Up @@ -1497,4 +1499,30 @@ private function matchHasNonOrganisationFields(array $match): bool

return false;
}//end matchHasNonOrganisationFields()

/**
* Resolve the effective authorization for a schema.
*
* Delegates to PermissionHandler::resolveAuthorization() which handles
* register cascade and role expansion. Falls back to schema-only
* authorization if PermissionHandler is not available.
*
* @param Schema $schema The schema to resolve authorization for.
*
* @return array|null The effective authorization array.
*/
private function resolveSchemaAuthorization(Schema $schema): ?array
{
try {
$permissionHandler = $this->container->get(PermissionHandler::class);
return $permissionHandler->resolveAuthorization($schema);
} catch (\Throwable $e) {
// Fallback to direct schema authorization if PermissionHandler unavailable.
$this->logger->debug(
message: '[MagicRbacHandler] PermissionHandler unavailable, using schema auth directly',
context: ['file' => __FILE__, 'line' => __LINE__, 'error' => $e->getMessage()]
);
return $schema->getAuthorization();
}
}//end resolveSchemaAuthorization()
}//end class
14 changes: 8 additions & 6 deletions lib/Migration/Version1Date20250828120000.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,17 @@
*/
class Version1Date20250828120000 extends SimpleMigrationStep
{
/**
* @param IDBConnection $connection The database connection
* @param IConfig $config The configuration interface
*/
/**
* Constructor.
*
* @param IDBConnection $connection The database connection
* @param IConfig $config The configuration interface
*/
public function __construct(
private readonly IDBConnection $connection,
private readonly IConfig $config,
) {
}
}//end __construct()

/**
* Apply database schema changes for faceting performance.
Expand All @@ -65,7 +67,7 @@ public function __construct(
*/
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper
{
/**
/*
* @var ISchemaWrapper $schema
*/

Expand Down
Loading
Loading