diff --git a/.env.example b/.env.example index d0d4b37d55..61d3f0d35b 100755 --- a/.env.example +++ b/.env.example @@ -258,5 +258,11 @@ MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}" # Whether or not to automatically register new users upon first login #SAML2_AUTO_REGISTER_NEW_USERS=false -# Should normal user allowed to create projects +# Should normal users be allowed to create projects # USER_CREATE_PROJECTS = false + +# The maximum visibility level for user-created projects on this instance. +# Instance admins are able to override this setting and set project visibility +# to anything. Thus, this setting is only meaningful if USER_CREATE_PROJECTS=true. +# Options: PUBLIC, PROTECTED, PRIVATE +# MAX_PROJECT_VISIBILITY=PUBLIC diff --git a/app/Http/Controllers/ProjectController.php b/app/Http/Controllers/ProjectController.php index 02cf14fc8d..59fa9cec0c 100644 --- a/app/Http/Controllers/ProjectController.php +++ b/app/Http/Controllers/ProjectController.php @@ -142,6 +142,8 @@ public function apiCreateProject(): JsonResponse $response['vcsviewers'] = array_map($callback, array_keys($viewers)); + $response['max_project_visibility'] = $User->admin ? 'PUBLIC' : config('cdash.max_project_visibility'); + $pageTimer->end($response); return response()->json(cast_data_for_JSON($response)); } diff --git a/app/Rules/ProjectVisibilityAllowed.php b/app/Rules/ProjectVisibilityAllowed.php new file mode 100644 index 0000000000..caea44137f --- /dev/null +++ b/app/Rules/ProjectVisibilityAllowed.php @@ -0,0 +1,46 @@ + 2) { + $fail('Invalid project visibility setting'); + } + + $user = Auth::user(); + // Admins can always set the project to whatever visibility they want + if ($user === null || !$user->admin) { + $max_visibility = match (Str::upper(config('cdash.max_project_visibility'))) { + 'PUBLIC' => Project::ACCESS_PUBLIC, + 'PROTECTED' => Project::ACCESS_PROTECTED, + 'PRIVATE' => Project::ACCESS_PRIVATE, + default => $fail('This instance contains an improper MAX_PROJECT_VISIBILITY configuration.'), + }; + + // This horrible logic is an unfortunate byproduct of the decision to misorder the access numbering scheme, + // which prevents range-based logic... + if ($max_visibility === Project::ACCESS_PROTECTED && $value === Project::ACCESS_PUBLIC) { + $fail('This instance is only configured to contain protected or private projects.'); + } elseif ($max_visibility === Project::ACCESS_PRIVATE && ($value === Project::ACCESS_PUBLIC || $value === Project::ACCESS_PROTECTED)) { + $fail('This instance is only configured to contain private projects.'); + } + } + } +} diff --git a/app/cdash/public/api/v1/project.php b/app/cdash/public/api/v1/project.php index f9913c0451..9c537dfacc 100644 --- a/app/cdash/public/api/v1/project.php +++ b/app/cdash/public/api/v1/project.php @@ -19,11 +19,13 @@ require_once 'include/api_common.php'; +use App\Rules\ProjectVisibilityAllowed; use App\Utils\RepositoryUtils; use CDash\Model\Project; use CDash\Model\UserProject; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Gate; +use Illuminate\Support\Facades\Validator; // Read input parameters (if any). $rest_input = file_get_contents('php://input'); @@ -256,6 +258,14 @@ function populate_project($Project) $project_settings['CvsUrl'] = str_replace('&', '&', $cvsurl); } + if (Validator::make([ + 'visibility' => $project_settings['Public'], + ], [ + 'visibility' => new ProjectVisibilityAllowed(), + ])->fails()) { + abort(403, "Project visibility {$project_settings['Public']} prohibited for this instance."); + } + foreach ($project_settings as $k => $v) { $Project->{$k} = $v; } diff --git a/config/cdash.php b/config/cdash.php index 3e0732fa41..4cbdc376a3 100755 --- a/config/cdash.php +++ b/config/cdash.php @@ -81,5 +81,7 @@ 'unlimited_projects' => $unlimited_projects, 'use_compression' => env('USE_COMPRESSION', true), 'user_create_projects' => env('USER_CREATE_PROJECTS', false), + // Defaults to public. Only meaningful if USER_CREATE_PROJECT=true. + 'max_project_visibility' => env('MAX_PROJECT_VISIBILITY', 'PUBLIC'), 'use_vcs_api' => env('USE_VCS_API', true), ]; diff --git a/graphql/schema.graphql b/graphql/schema.graphql index e763b14ee5..c942757292 100644 --- a/graphql/schema.graphql +++ b/graphql/schema.graphql @@ -111,7 +111,7 @@ input CreateProjectInput { homeurl: String! "Visibility." - visibility: ProjectVisibility! @rename(attribute: "public") + visibility: ProjectVisibility! @rename(attribute: "public") @rules(attribute: "public", apply: ["App\\Rules\\ProjectVisibilityAllowed"]) } diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 592b2b193d..b3ee6cb3dd 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -29726,7 +29726,7 @@ parameters: - message: "#^Dynamic call to static method Illuminate\\\\Testing\\\\TestResponse\\:\\:assertGraphQLErrorMessage\\(\\)\\.$#" - count: 3 + count: 4 path: tests/Feature/GraphQL/ProjectTest.php - diff --git a/resources/js/components/EditProject.vue b/resources/js/components/EditProject.vue index e9411fee61..e775d47e6b 100644 --- a/resources/js/components/EditProject.vue +++ b/resources/js/components/EditProject.vue @@ -371,13 +371,21 @@ @change="cdash.changesmade = true" @focus="showHelp('public_help')" > - - - diff --git a/tests/Feature/GraphQL/ProjectTest.php b/tests/Feature/GraphQL/ProjectTest.php index a98adf692e..defa4c4e19 100644 --- a/tests/Feature/GraphQL/ProjectTest.php +++ b/tests/Feature/GraphQL/ProjectTest.php @@ -635,4 +635,81 @@ public function testProjectVisibilityValue(): void ], ], true); } + + /** + * @return array{ + * array{ + * string, + * string, + * string, + * bool + * } + * } + */ + public function createProjectVisibilityRules(): array + { + return [ + ['normal', 'PUBLIC', 'PUBLIC', true], + ['normal', 'PROTECTED', 'PUBLIC', true], + ['normal', 'PRIVATE', 'PUBLIC', true], + ['normal', 'PUBLIC', 'PROTECTED', false], + ['normal', 'PROTECTED', 'PROTECTED', true], + ['normal', 'PRIVATE', 'PROTECTED', true], + ['normal', 'PUBLIC', 'PRIVATE', false], + ['normal', 'PROTECTED', 'PRIVATE', false], + ['normal', 'PRIVATE', 'PRIVATE', true], + ['admin', 'PUBLIC', 'PUBLIC', true], + ['admin', 'PROTECTED', 'PUBLIC', true], + ['admin', 'PRIVATE', 'PUBLIC', true], + ['admin', 'PUBLIC', 'PROTECTED', true], + ['admin', 'PROTECTED', 'PROTECTED', true], + ['admin', 'PRIVATE', 'PROTECTED', true], + ['admin', 'PUBLIC', 'PRIVATE', true], + ['admin', 'PROTECTED', 'PRIVATE', true], + ['admin', 'PRIVATE', 'PRIVATE', true], + ]; + } + + /** + * @dataProvider createProjectVisibilityRules + */ + public function testCreateProjectMaxVisibility(string $user, string $visibility, string $max_visibility, bool $can_create): void + { + Config::set('cdash.user_create_projects', true); + Config::set('cdash.max_project_visibility', $max_visibility); + + $name = 'test-project' . Str::uuid(); + $response = $this->actingAs($this->users[$user])->graphQL(' + mutation CreateProject($input: CreateProjectInput!) { + createProject(input: $input) { + visibility + } + } + ', [ + 'input' => [ + 'name' => $name, + 'description' => 'test', + 'homeurl' => 'https://cdash.org', + 'visibility' => $visibility, + ], + ]); + + if ($can_create) { + $project = Project::where('name', $name)->firstOrFail(); + $response->assertJson([ + 'data' => [ + 'createProject' => [ + 'visibility' => $visibility, + ], + ], + ], true); + $project->delete(); + } else { + // A final check to ensure this project wasn't created anyway + $this->assertDatabaseMissing(Project::class, [ + 'name' => $name, + ]); + $response->assertGraphQLErrorMessage('Validation failed for the field [createProject].'); + } + } }