diff --git a/NOTICE.txt b/NOTICE.txt index 5fa3aad87..daf3ffa52 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -200,6 +200,11 @@ This product depends on Hibernate Validator, distributed by Red Hat, Inc: * License: licenses/LICENSE.hibernate.al20.txt * Homepage: http://hibernate.org/validator/ +This product depends on itu, distributed by Morten Haraldsen: + + * License: licenses/LICENSE.itu.al20.txt (Apache License v2.0) + * Homepage: https://github.com/ethlo/itu + This product depends on Jackson, distributed by FasterXML, LLC: * License: licenses/LICENSE.jackson.al20.txt (Apache License v2.0) @@ -260,6 +265,11 @@ This product depends on JSch, distributed by JCraft, Inc: * License: licenses/LICENSE.jsch.bsd.txt (New BSD License) * Homepage: http://www.jcraft.com/jsch/ +This product depends on json-schema-validator, distributed by networknt: + + * License: licenses/LICENSE.json-schema-validator.al20.txt (Apache License v2.0) + * Homepage: https://github.com/networknt/json-schema-validator + This product depends on JsonUnit, distributed by Lukáš Křečan: * License: licenses/LICENSE.jsonunit.al20.txt (Apache License v2.0) diff --git a/common/src/main/java/com/linecorp/centraldogma/internal/api/v1/CreateProjectRequest.java b/common/src/main/java/com/linecorp/centraldogma/internal/api/v1/CreateProjectRequest.java index ccb62fd81..02c045976 100644 --- a/common/src/main/java/com/linecorp/centraldogma/internal/api/v1/CreateProjectRequest.java +++ b/common/src/main/java/com/linecorp/centraldogma/internal/api/v1/CreateProjectRequest.java @@ -24,6 +24,7 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; import com.google.common.base.MoreObjects; import com.google.common.collect.ImmutableSet; @@ -35,14 +36,18 @@ public class CreateProjectRequest { private final String name; private final Set owners; private final Set members; + @Nullable + private final JsonNode properties; @JsonCreator public CreateProjectRequest(@JsonProperty("name") String name, @JsonProperty("owners") @Nullable Set owners, - @JsonProperty("members") @Nullable Set members) { + @JsonProperty("members") @Nullable Set members, + @JsonProperty("properties") @Nullable JsonNode properties) { this.name = validateProjectName(name, "name", false); this.owners = owners != null ? ImmutableSet.copyOf(owners) : ImmutableSet.of(); this.members = members != null ? ImmutableSet.copyOf(members) : ImmutableSet.of(); + this.properties = properties; } @JsonProperty @@ -60,12 +65,19 @@ public Set members() { return members; } + @Nullable + @JsonProperty + public JsonNode properties() { + return properties; + } + @Override public String toString() { return MoreObjects.toStringHelper(this) .add("name", name()) .add("owners", owners()) .add("members", members()) + .add("properties", properties()) .toString(); } } diff --git a/common/src/main/java/com/linecorp/centraldogma/internal/api/v1/CreateRepositoryRequest.java b/common/src/main/java/com/linecorp/centraldogma/internal/api/v1/CreateRepositoryRequest.java index efdc956ed..094de57b3 100644 --- a/common/src/main/java/com/linecorp/centraldogma/internal/api/v1/CreateRepositoryRequest.java +++ b/common/src/main/java/com/linecorp/centraldogma/internal/api/v1/CreateRepositoryRequest.java @@ -23,6 +23,7 @@ import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; import com.google.common.base.MoreObjects; /** @@ -32,12 +33,16 @@ public class CreateRepositoryRequest { private final String name; private final boolean encrypt; + @Nullable + private final JsonNode properties; @JsonCreator public CreateRepositoryRequest(@JsonProperty("name") String name, - @JsonProperty("encrypt") @Nullable Boolean encrypt) { + @JsonProperty("encrypt") @Nullable Boolean encrypt, + @JsonProperty("properties") @Nullable JsonNode properties) { this.name = validateRepositoryName(name, "name"); this.encrypt = firstNonNull(encrypt, false); + this.properties = properties; } @JsonProperty @@ -50,11 +55,18 @@ public boolean encrypt() { return encrypt; } + @Nullable + @JsonProperty + public JsonNode properties() { + return properties; + } + @Override public String toString() { return MoreObjects.toStringHelper(this) .add("name", name()) .add("encrypt", encrypt) + .add("properties", properties()) .toString(); } } diff --git a/dependencies.toml b/dependencies.toml index 1306af520..da1029d2b 100644 --- a/dependencies.toml +++ b/dependencies.toml @@ -48,6 +48,7 @@ junit-pioneer = "2.3.0" jsch = "0.1.55" # Don't update `json-path` version json-path = "2.2.0" +json-schema-validator = "1.5.9" # 3.0.0 requires java 17 json-unit = "2.38.0" jsoup = "1.22.2" # JSoup is only used for Gradle script. @@ -286,6 +287,12 @@ module = "com.jayway.jsonpath:json-path" version.ref = "json-path" relocations = { from = "com.jayway.jsonpath", to = "com.linecorp.centraldogma.internal.shaded.jsonpath" } +[libraries.json-schema-validator] +module = "com.networknt:json-schema-validator" +version.ref = "json-schema-validator" +# Exclude the differently-versioned jackson-dataformat-yaml edge; :common already provides it. +exclusions = "com.fasterxml.jackson.dataformat:jackson-dataformat-yaml" + [libraries.json-unit] module = "net.javacrumbs.json-unit:json-unit" version.ref = "json-unit" diff --git a/licenses/LICENSE.itu.al20.txt b/licenses/LICENSE.itu.al20.txt new file mode 100644 index 000000000..83e11ffc8 --- /dev/null +++ b/licenses/LICENSE.itu.al20.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + https://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2017 Jayway + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/licenses/LICENSE.json-schema-validator.al20.txt b/licenses/LICENSE.json-schema-validator.al20.txt new file mode 100644 index 000000000..83e11ffc8 --- /dev/null +++ b/licenses/LICENSE.json-schema-validator.al20.txt @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + https://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2017 Jayway + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/server/build.gradle b/server/build.gradle index e95be7534..80763d4fa 100644 --- a/server/build.gradle +++ b/server/build.gradle @@ -39,6 +39,9 @@ dependencies { implementation libs.jgit6 + // JSON Schema validation for metadata properties + implementation libs.json.schema.validator + // Micrometer implementation libs.micrometer.core implementation libs.micrometer.prometheus diff --git a/server/src/main/java/com/linecorp/centraldogma/server/CentralDogma.java b/server/src/main/java/com/linecorp/centraldogma/server/CentralDogma.java index 4a95a9dde..ceadbf8ed 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/CentralDogma.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/CentralDogma.java @@ -151,6 +151,7 @@ import com.linecorp.centraldogma.server.internal.api.HttpApiExceptionHandler; import com.linecorp.centraldogma.server.internal.api.LoggerService; import com.linecorp.centraldogma.server.internal.api.MetadataApiService; +import com.linecorp.centraldogma.server.internal.api.MetadataPropertiesService; import com.linecorp.centraldogma.server.internal.api.MirroringServiceV1; import com.linecorp.centraldogma.server.internal.api.ProjectServiceV1; import com.linecorp.centraldogma.server.internal.api.RepositoryServiceV1; @@ -165,6 +166,7 @@ import com.linecorp.centraldogma.server.internal.api.sysadmin.MirrorAccessControlService; import com.linecorp.centraldogma.server.internal.api.sysadmin.ServerStatusService; import com.linecorp.centraldogma.server.internal.api.variable.VariableServiceV1; +import com.linecorp.centraldogma.server.internal.metadata.MetadataPropertiesValidator; import com.linecorp.centraldogma.server.internal.mirror.DefaultMirrorAccessController; import com.linecorp.centraldogma.server.internal.mirror.DefaultMirroringServicePlugin; import com.linecorp.centraldogma.server.internal.mirror.MirrorAccessControl; @@ -1009,13 +1011,18 @@ private void configureHttpApi(ServerBuilder sb, } assert statusManager != null; + final MetadataPropertiesValidator metadataPropertiesValidator = + new MetadataPropertiesValidator(cfg.metadataProperties()); final ContextPathServicesBuilder apiV1ServiceBuilder = sb.contextPath(API_V1_PATH_PREFIX); apiV1ServiceBuilder .annotatedService(new ServerStatusService(executor, statusManager)) - .annotatedService(new ProjectServiceV1(projectApiManager, executor)) - .annotatedService(new RepositoryServiceV1(executor, mds, encryptionStorageManager)) + .annotatedService(new ProjectServiceV1(projectApiManager, executor, + metadataPropertiesValidator)) + .annotatedService(new RepositoryServiceV1(executor, mds, encryptionStorageManager, + metadataPropertiesValidator)) .annotatedService(new CredentialServiceV1(projectApiManager, executor)) - .annotatedService(new VariableServiceV1(pm, executor)); + .annotatedService(new VariableServiceV1(pm, executor)) + .annotatedService(new MetadataPropertiesService(cfg.metadataProperties())); if (LOGBACK_ENABLED) { apiV1ServiceBuilder.annotatedService(new LoggerService()); } @@ -1059,7 +1066,8 @@ protected HttpResponse doGet(ServiceRequestContext ctx, HttpRequest req) { assert sessionManager != null : "sessionManager"; apiV1ServiceBuilder .annotatedService(new MetadataApiService(executor, mds, authCfg.loginNameNormalizer())) - .annotatedService(new AppIdentityRegistryService(executor, mds, mtlsEnabled)); + .annotatedService(new AppIdentityRegistryService(executor, mds, mtlsEnabled, + metadataPropertiesValidator)); // authentication services: Optional.ofNullable(authProvider.loginApiService()) diff --git a/server/src/main/java/com/linecorp/centraldogma/server/CentralDogmaBuilder.java b/server/src/main/java/com/linecorp/centraldogma/server/CentralDogmaBuilder.java index 8090fa4f2..3d41e3dbe 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/CentralDogmaBuilder.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/CentralDogmaBuilder.java @@ -137,6 +137,8 @@ public final class CentralDogmaBuilder { private ManagementConfig managementConfig; @Nullable private ZoneConfig zoneConfig; + @Nullable + private MetadataPropertiesConfig metadataProperties; private boolean enableThriftService = true; /** @@ -583,6 +585,16 @@ public CentralDogmaBuilder zone(ZoneConfig zoneConfig) { return this; } + /** + * Specifies the {@link MetadataPropertiesConfig} that declares the additional metadata properties of + * projects, repositories and app identities. + */ + public CentralDogmaBuilder metadataProperties(MetadataPropertiesConfig metadataProperties) { + requireNonNull(metadataProperties, "metadataProperties"); + this.metadataProperties = metadataProperties; + return this; + } + /** * Enables or disables the Thrift service. The Thrift service is enabled by default. * Note that if a Thrift dependency is not found on the classpath, the Thrift service will be @@ -628,6 +640,6 @@ private CentralDogmaConfig buildConfig() { webAppEnabled, webAppTitle, replicationConfig, null, accessLogFormat, authCfg, corsConfig, pluginConfigs, managementConfig, zoneConfig, - enableThriftService); + metadataProperties, enableThriftService); } } diff --git a/server/src/main/java/com/linecorp/centraldogma/server/CentralDogmaConfig.java b/server/src/main/java/com/linecorp/centraldogma/server/CentralDogmaConfig.java index af9baaa45..2b73ab6db 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/CentralDogmaConfig.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/CentralDogmaConfig.java @@ -270,6 +270,9 @@ public static CentralDogmaConfig load(String json) throws JsonMappingException, @Nullable private final ZoneConfig zoneConfig; + @Nullable + private final MetadataPropertiesConfig metadataProperties; + private final boolean enableThriftService; CentralDogmaConfig( @@ -300,6 +303,7 @@ public static CentralDogmaConfig load(String json) throws JsonMappingException, @JsonProperty("pluginConfigs") @Nullable List pluginConfigs, @JsonProperty("management") @Nullable ManagementConfig managementConfig, @JsonProperty("zone") @Nullable ZoneConfig zoneConfig, + @JsonProperty("metadataProperties") @Nullable MetadataPropertiesConfig metadataProperties, @JsonProperty("enableThriftService") @Nullable Boolean enableThriftService) { this.dataDir = requireNonNull(dataDir, "dataDir"); @@ -352,6 +356,7 @@ public static CentralDogmaConfig load(String json) throws JsonMappingException, toImmutableMap(PluginConfig::getClass, Function.identity())); this.managementConfig = managementConfig; this.zoneConfig = zoneConfig; + this.metadataProperties = metadataProperties; this.enableThriftService = firstNonNull(enableThriftService, true); } @@ -601,6 +606,16 @@ public ZoneConfig zone() { return zoneConfig; } + /** + * Returns the {@link MetadataPropertiesConfig} that declares the additional metadata properties of + * projects, repositories and app identities. + */ + @Nullable + @JsonProperty("metadataProperties") + public MetadataPropertiesConfig metadataProperties() { + return metadataProperties; + } + /** * Returns whether to enable the Thrift service. */ diff --git a/server/src/main/java/com/linecorp/centraldogma/server/MetadataPropertiesConfig.java b/server/src/main/java/com/linecorp/centraldogma/server/MetadataPropertiesConfig.java new file mode 100644 index 000000000..5dce2ff57 --- /dev/null +++ b/server/src/main/java/com/linecorp/centraldogma/server/MetadataPropertiesConfig.java @@ -0,0 +1,112 @@ +/* + * Copyright 2026 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.centraldogma.server; + +import java.util.Objects; + +import org.jspecify.annotations.Nullable; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; +import com.google.common.base.MoreObjects; + +/** + * A configuration for the additional metadata properties of projects, repositories and app identities. + * Each field is a JSON Schema that the {@code properties} of the + * corresponding resource must conform to at creation time. + */ +@JsonInclude(Include.NON_NULL) +public final class MetadataPropertiesConfig { + + @Nullable + private final JsonNode project; + @Nullable + private final JsonNode repo; + @Nullable + private final JsonNode appIdentity; + + /** + * Creates a new instance. + */ + @JsonCreator + public MetadataPropertiesConfig(@JsonProperty("project") @Nullable JsonNode project, + @JsonProperty("repo") @Nullable JsonNode repo, + @JsonProperty("appIdentity") @Nullable JsonNode appIdentity) { + this.project = project; + this.repo = repo; + this.appIdentity = appIdentity; + } + + /** + * Returns the JSON Schema for the properties of a project. + */ + @Nullable + @JsonProperty("project") + public JsonNode project() { + return project; + } + + /** + * Returns the JSON Schema for the properties of a repository. + */ + @Nullable + @JsonProperty("repo") + public JsonNode repo() { + return repo; + } + + /** + * Returns the JSON Schema for the properties of an app identity. + */ + @Nullable + @JsonProperty("appIdentity") + public JsonNode appIdentity() { + return appIdentity; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof MetadataPropertiesConfig)) { + return false; + } + final MetadataPropertiesConfig that = (MetadataPropertiesConfig) o; + return Objects.equals(project, that.project) && + Objects.equals(repo, that.repo) && + Objects.equals(appIdentity, that.appIdentity); + } + + @Override + public int hashCode() { + return Objects.hash(project, repo, appIdentity); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .omitNullValues() + .add("project", project) + .add("repo", repo) + .add("appIdentity", appIdentity) + .toString(); + } +} diff --git a/server/src/main/java/com/linecorp/centraldogma/server/command/Command.java b/server/src/main/java/com/linecorp/centraldogma/server/command/Command.java index 8045c7b01..c8d95f188 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/command/Command.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/command/Command.java @@ -25,6 +25,7 @@ import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonSubTypes.Type; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.databind.JsonNode; import com.google.common.collect.ImmutableList; import com.linecorp.centraldogma.common.Author; @@ -83,12 +84,15 @@ static Command createProject(Author author, String name) { * * @param author the author who is creating the project * @param name the name of the project which is supposed to be created - * @param wdekDetails the wrapped data encryption key for the project + * @param wdekDetails the wrapped data encryption key for the project, + * or {@code null} if the project is not encrypted + * @param properties the additional metadata properties of the project */ - static Command createProject(Author author, String name, WrappedDekDetails wdekDetails) { + static Command createProject(Author author, String name, + @Nullable WrappedDekDetails wdekDetails, + @Nullable JsonNode properties) { requireNonNull(author, "author"); - requireNonNull(wdekDetails, "wdekDetails"); - return new CreateProjectCommand(null, author, name, wdekDetails); + return new CreateProjectCommand(null, author, name, wdekDetails, properties); } /** @@ -100,7 +104,7 @@ static Command createProject(Author author, String name, WrappedDekDetails */ static Command createProject(@Nullable Long timestamp, Author author, String name) { requireNonNull(author, "author"); - return new CreateProjectCommand(timestamp, author, name, null); + return new CreateProjectCommand(timestamp, author, name, null, null); } /** diff --git a/server/src/main/java/com/linecorp/centraldogma/server/command/CreateProjectCommand.java b/server/src/main/java/com/linecorp/centraldogma/server/command/CreateProjectCommand.java index 682ba4671..8d8fe9b07 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/command/CreateProjectCommand.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/command/CreateProjectCommand.java @@ -27,6 +27,7 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude.Include; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; import com.google.common.base.MoreObjects.ToStringHelper; import com.linecorp.centraldogma.common.Author; @@ -41,15 +42,19 @@ public final class CreateProjectCommand extends RootCommand { private final String projectName; @Nullable private final WrappedDekDetails wdekDetails; + @Nullable + private final JsonNode properties; @JsonCreator CreateProjectCommand(@JsonProperty("timestamp") @Nullable Long timestamp, @JsonProperty("author") @Nullable Author author, @JsonProperty("projectName") String projectName, - @JsonProperty("wdekDetails") @Nullable WrappedDekDetails wdekDetails) { + @JsonProperty("wdekDetails") @Nullable WrappedDekDetails wdekDetails, + @JsonProperty("properties") @Nullable JsonNode properties) { super(CommandType.CREATE_PROJECT, timestamp, author); this.projectName = requireNonNull(projectName, "projectName"); this.wdekDetails = wdekDetails; + this.properties = properties; if (wdekDetails != null) { checkArgument(wdekDetails.projectName().equals(projectName), "projectName: %s, (expected: %s", projectName, wdekDetails.projectName()); @@ -73,6 +78,15 @@ public WrappedDekDetails wdekDetails() { return wdekDetails; } + /** + * Returns the additional metadata properties of the project. + */ + @Nullable + @JsonProperty + public JsonNode properties() { + return properties; + } + @Override public boolean equals(Object obj) { if (this == obj) { @@ -86,18 +100,21 @@ public boolean equals(Object obj) { final CreateProjectCommand that = (CreateProjectCommand) obj; return super.equals(obj) && projectName.equals(that.projectName) && - Objects.equals(wdekDetails, that.wdekDetails); + Objects.equals(wdekDetails, that.wdekDetails) && + Objects.equals(properties, that.properties); } @Override public int hashCode() { - return (projectName.hashCode() * 31 + Objects.hashCode(wdekDetails)) * 31 + super.hashCode(); + return ((projectName.hashCode() * 31 + Objects.hashCode(wdekDetails)) * 31 + + Objects.hashCode(properties)) * 31 + super.hashCode(); } @Override ToStringHelper toStringHelper() { return super.toStringHelper().omitNullValues() .add("projectName", projectName) - .add("wdekDetails", wdekDetails); + .add("wdekDetails", wdekDetails) + .add("properties", properties); } } diff --git a/server/src/main/java/com/linecorp/centraldogma/server/command/StandaloneCommandExecutor.java b/server/src/main/java/com/linecorp/centraldogma/server/command/StandaloneCommandExecutor.java index 1a8fc9cbb..1014efecc 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/command/StandaloneCommandExecutor.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/command/StandaloneCommandExecutor.java @@ -254,7 +254,7 @@ private CompletableFuture createProject(CreateProjectCommand c) { } try { - projectManager.create(c.projectName(), c.timestamp(), c.author(), encrypt); + projectManager.create(c.projectName(), c.timestamp(), c.author(), encrypt, c.properties()); } catch (Throwable t) { if (encrypt) { try { diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/MetadataPropertiesService.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/MetadataPropertiesService.java new file mode 100644 index 000000000..5ca10d4e5 --- /dev/null +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/MetadataPropertiesService.java @@ -0,0 +1,53 @@ +/* + * Copyright 2026 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.centraldogma.server.internal.api; + +import org.jspecify.annotations.Nullable; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; + +import com.linecorp.armeria.server.annotation.Get; +import com.linecorp.armeria.server.annotation.ProducesJson; +import com.linecorp.centraldogma.internal.Jackson; +import com.linecorp.centraldogma.server.MetadataPropertiesConfig; + +/** + * Annotated service object for retrieving the metadata properties configuration, so that clients such as + * the web UI can render input forms for the declared properties. + */ +@ProducesJson +public final class MetadataPropertiesService { + + private final JsonNode metadataProperties; + + public MetadataPropertiesService(@Nullable MetadataPropertiesConfig config) { + metadataProperties = config != null ? Jackson.valueToTree(config) + : JsonNodeFactory.instance.objectNode(); + } + + /** + * GET /metadataProperties + * + *

Returns the JSON Schemas of the additional metadata properties declared in the server + * configuration, keyed by resource type. An empty object is returned if nothing is declared. + */ + @Get("/metadataProperties") + public JsonNode metadataProperties() { + return metadataProperties; + } +} diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/ProjectServiceV1.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/ProjectServiceV1.java index 970ff7527..bf3cf546c 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/ProjectServiceV1.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/ProjectServiceV1.java @@ -51,6 +51,8 @@ import com.linecorp.centraldogma.server.internal.api.auth.RequiresProjectRole; import com.linecorp.centraldogma.server.internal.api.auth.RequiresSystemAdministrator; import com.linecorp.centraldogma.server.internal.api.converter.CreateApiResponseConverter; +import com.linecorp.centraldogma.server.internal.metadata.MetadataPropertiesValidator; +import com.linecorp.centraldogma.server.internal.metadata.MetadataPropertiesValidator.ResourceType; import com.linecorp.centraldogma.server.internal.storage.project.ProjectApiManager; import com.linecorp.centraldogma.server.metadata.AppIdentityRegistration; import com.linecorp.centraldogma.server.metadata.Member; @@ -67,10 +69,14 @@ public class ProjectServiceV1 extends AbstractService { private final ProjectApiManager projectApiManager; + private final MetadataPropertiesValidator metadataPropertiesValidator; - public ProjectServiceV1(ProjectApiManager projectApiManager, CommandExecutor executor) { + public ProjectServiceV1(ProjectApiManager projectApiManager, CommandExecutor executor, + MetadataPropertiesValidator metadataPropertiesValidator) { super(executor); this.projectApiManager = requireNonNull(projectApiManager, "projectApiManager"); + this.metadataPropertiesValidator = requireNonNull(metadataPropertiesValidator, + "metadataPropertiesValidator"); } /** @@ -138,10 +144,13 @@ private static ProjectRole getUserRole(Project project, User user) { @StatusCode(201) @ResponseConverter(CreateApiResponseConverter.class) public CompletableFuture createProject(CreateProjectRequest request, Author author, User user) { - return projectApiManager.createProject(request.name(), author).handle(returnOrThrow(() -> { - final Project project = projectApiManager.getProject(request.name(), user); - return newProjectDto(project, ProjectRole.OWNER); - })); + final JsonNode properties = + metadataPropertiesValidator.validate(ResourceType.PROJECT, request.properties()); + return projectApiManager.createProject(request.name(), author, properties) + .handle(returnOrThrow(() -> { + final Project project = projectApiManager.getProject(request.name(), user); + return newProjectDto(project, ProjectRole.OWNER); + })); } /** diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/RepositoryServiceUtil.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/RepositoryServiceUtil.java index da9555f7b..99d46b036 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/RepositoryServiceUtil.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/RepositoryServiceUtil.java @@ -22,6 +22,7 @@ import org.jspecify.annotations.Nullable; +import com.fasterxml.jackson.databind.JsonNode; import com.google.common.collect.ImmutableMap; import com.linecorp.centraldogma.common.Author; @@ -42,7 +43,8 @@ public final class RepositoryServiceUtil { public static CompletableFuture createRepository( CommandExecutor commandExecutor, MetadataService mds, Author author, String projectName, String repoName, boolean encrypt, - @Nullable EncryptionStorageManager encryptionStorageManager) { + @Nullable EncryptionStorageManager encryptionStorageManager, + @Nullable JsonNode properties) { final Map users; final Map appIds; if (author.isAppIdentity()) { @@ -56,7 +58,7 @@ public static CompletableFuture createRepository( final Roles roles = new Roles(DEFAULT_PROJECT_ROLES, users, null, appIds); final RepositoryMetadata repositoryMetadata = - RepositoryMetadata.of(repoName, roles, UserAndTimestamp.of(author)); + RepositoryMetadata.of(repoName, roles, UserAndTimestamp.of(author), properties); if (!encrypt) { return commandExecutor.execute(Command.createRepository(author, projectName, repoName)) diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/RepositoryServiceV1.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/RepositoryServiceV1.java index 152b784c0..72fcd5b78 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/RepositoryServiceV1.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/RepositoryServiceV1.java @@ -64,6 +64,8 @@ import com.linecorp.centraldogma.server.internal.api.auth.RequiresRepositoryRole; import com.linecorp.centraldogma.server.internal.api.auth.RequiresSystemAdministrator; import com.linecorp.centraldogma.server.internal.api.converter.CreateApiResponseConverter; +import com.linecorp.centraldogma.server.internal.metadata.MetadataPropertiesValidator; +import com.linecorp.centraldogma.server.internal.metadata.MetadataPropertiesValidator.ResourceType; import com.linecorp.centraldogma.server.metadata.MetadataService; import com.linecorp.centraldogma.server.metadata.ProjectMetadata; import com.linecorp.centraldogma.server.metadata.RepositoryMetadata; @@ -86,12 +88,16 @@ public class RepositoryServiceV1 extends AbstractService { private final MetadataService mds; private final EncryptionStorageManager encryptionStorageManager; + private final MetadataPropertiesValidator metadataPropertiesValidator; public RepositoryServiceV1(CommandExecutor executor, MetadataService mds, - EncryptionStorageManager encryptionStorageManager) { + EncryptionStorageManager encryptionStorageManager, + MetadataPropertiesValidator metadataPropertiesValidator) { super(executor); this.mds = requireNonNull(mds, "mds"); this.encryptionStorageManager = requireNonNull(encryptionStorageManager, "encryptionStorageManager"); + this.metadataPropertiesValidator = requireNonNull(metadataPropertiesValidator, + "metadataPropertiesValidator"); } /** @@ -199,11 +205,13 @@ public CompletableFuture createRepository(ServiceRequestContext c } final boolean encrypt = request.encrypt() || isEncryptedProject(project); + final JsonNode properties = + metadataPropertiesValidator.validate(ResourceType.REPO, request.properties()); final CommandExecutor commandExecutor = executor(); final CompletableFuture future = RepositoryServiceUtil.createRepository(commandExecutor, mds, author, project.name(), repoName, - encrypt, encryptionStorageManager); + encrypt, encryptionStorageManager, properties); return future.handle(returnOrThrow(() -> { final Repository repository = project.repos().get(repoName); return newRepositoryDto(repository, repositoryStatus(repository)); diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/sysadmin/AppIdentityRegistryService.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/sysadmin/AppIdentityRegistryService.java index 0131e6dc9..cf72be425 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/internal/api/sysadmin/AppIdentityRegistryService.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/api/sysadmin/AppIdentityRegistryService.java @@ -25,6 +25,7 @@ import org.jspecify.annotations.Nullable; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; @@ -54,6 +55,8 @@ import com.linecorp.centraldogma.server.internal.api.HttpApiUtil; import com.linecorp.centraldogma.server.internal.api.auth.RequiresSystemAdministrator; import com.linecorp.centraldogma.server.internal.api.converter.CreateApiResponseConverter; +import com.linecorp.centraldogma.server.internal.metadata.MetadataPropertiesValidator; +import com.linecorp.centraldogma.server.internal.metadata.MetadataPropertiesValidator.ResourceType; import com.linecorp.centraldogma.server.metadata.AppIdentity; import com.linecorp.centraldogma.server.metadata.AppIdentityType; import com.linecorp.centraldogma.server.metadata.MetadataService; @@ -79,11 +82,15 @@ public final class AppIdentityRegistryService extends AbstractService { private final MetadataService mds; private final boolean mtlsEnabled; + private final MetadataPropertiesValidator metadataPropertiesValidator; - public AppIdentityRegistryService(CommandExecutor executor, MetadataService mds, boolean mtlsEnabled) { + public AppIdentityRegistryService(CommandExecutor executor, MetadataService mds, boolean mtlsEnabled, + MetadataPropertiesValidator metadataPropertiesValidator) { super(executor); this.mds = requireNonNull(mds, "mds"); this.mtlsEnabled = mtlsEnabled; + this.metadataPropertiesValidator = requireNonNull(metadataPropertiesValidator, + "metadataPropertiesValidator"); } /** @@ -118,11 +125,19 @@ public CompletableFuture> createAppIdentity( @Param AppIdentityType type, @Param @Nullable String secret, @Param @Nullable String certificateId, + @Param @Nullable String properties, Author author, User loginUser) { if (!mtlsEnabled && type == AppIdentityType.CERTIFICATE) { throw new IllegalArgumentException( "Cannot create a CERTIFICATE type app identity when mTLS is disabled."); } + final JsonNode validatedProperties; + try { + validatedProperties = metadataPropertiesValidator.validate( + ResourceType.APP_IDENTITY, properties != null ? Jackson.readTree(properties) : null); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("properties must be a valid JSON object: " + properties, e); + } if (!loginUser.isSystemAdmin()) { checkArgument( @@ -138,15 +153,16 @@ public CompletableFuture> createAppIdentity( checkArgument(certificateId == null, "TOKEN type cannot have a certificateId: %s", certificateId); if (secret != null) { - future = mds.createToken(author, appId, secret, isSystemAdmin); + future = mds.createToken(author, appId, secret, isSystemAdmin, validatedProperties); } else { - future = mds.createToken(author, appId, isSystemAdmin); + future = mds.createToken(author, appId, isSystemAdmin, validatedProperties); } } else { checkArgument(certificateId != null, "CERTIFICATE type must have a certificateId."); checkArgument(secret == null, "CERTIFICATE type cannot have a secret: %s", secret); - future = mds.createCertificate(author, appId, certificateId, isSystemAdmin); + future = mds.createCertificate(author, appId, certificateId, isSystemAdmin, + validatedProperties); } return future.thenCompose(unused -> fetchAppIdentity(appId)) .thenApply(appIdentity -> { @@ -329,7 +345,7 @@ public Collection listTokens(User loginUser) { *

Returns a newly-generated token belonging to the current login user. * * @deprecated Use {@link #createAppIdentity( - * String, boolean, AppIdentityType, String, String, Author, User)}. + * String, boolean, AppIdentityType, String, String, String, Author, User)}. */ @Post("/tokens") @StatusCode(201) @@ -339,7 +355,7 @@ public CompletableFuture> createToken(@Param String appId, @Param @Default("false") boolean isSystemAdmin, @Param @Nullable String secret, Author author, User loginUser) { - return createAppIdentity(appId, isSystemAdmin, AppIdentityType.TOKEN, secret, null, + return createAppIdentity(appId, isSystemAdmin, AppIdentityType.TOKEN, secret, null, null, author, loginUser) .thenApply(responseEntity -> { final AppIdentity app = responseEntity.content(); diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/metadata/MetadataPropertiesValidator.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/metadata/MetadataPropertiesValidator.java new file mode 100644 index 000000000..28f4685c4 --- /dev/null +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/metadata/MetadataPropertiesValidator.java @@ -0,0 +1,154 @@ +/* + * Copyright 2026 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.centraldogma.server.internal.metadata; + +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.jspecify.annotations.Nullable; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.networknt.schema.JsonSchema; +import com.networknt.schema.JsonSchemaFactory; +import com.networknt.schema.SpecVersion.VersionFlag; +import com.networknt.schema.ValidationMessage; + +import com.linecorp.centraldogma.server.MetadataPropertiesConfig; + +/** + * Validates the {@code properties} of a resource against the JSON Schemas declared in + * {@link MetadataPropertiesConfig}. When a schema declares a top-level {@code properties} keyword, + * properties that are not declared in it are silently dropped so that a new property can be declared + * with a rolling restart. When a schema declares its shape in another way (e.g. {@code $ref} or + * {@code allOf}), nothing is dropped and the whole object is validated as is. + */ +public final class MetadataPropertiesValidator { + + /** + * The type of a resource that can have metadata properties. + */ + public enum ResourceType { + PROJECT("project"), + REPO("repo"), + APP_IDENTITY("appIdentity"); + + private final String key; + + ResourceType(String key) { + this.key = key; + } + } + + private final Map schemas; + // The top-level declared property names. An entry is absent when the schema declares no top-level + // "properties" keyword, in which case nothing is filtered. + private final Map> declaredProperties; + + public MetadataPropertiesValidator(@Nullable MetadataPropertiesConfig config) { + if (config == null) { + schemas = ImmutableMap.of(); + declaredProperties = ImmutableMap.of(); + return; + } + final JsonSchemaFactory factory = JsonSchemaFactory.getInstance(VersionFlag.V202012); + final ImmutableMap.Builder schemas = ImmutableMap.builder(); + final ImmutableMap.Builder> declaredProperties = ImmutableMap.builder(); + compile(factory, ResourceType.PROJECT, config.project(), schemas, declaredProperties); + compile(factory, ResourceType.REPO, config.repo(), schemas, declaredProperties); + compile(factory, ResourceType.APP_IDENTITY, config.appIdentity(), schemas, declaredProperties); + this.schemas = schemas.build(); + this.declaredProperties = declaredProperties.build(); + } + + private static void compile(JsonSchemaFactory factory, ResourceType type, @Nullable JsonNode schemaNode, + ImmutableMap.Builder schemas, + ImmutableMap.Builder> declaredProperties) { + if (schemaNode == null) { + return; + } + final JsonSchema schema; + try { + schema = factory.getSchema(schemaNode); + schema.initializeValidators(); + } catch (Exception e) { + throw new IllegalStateException( + "Invalid JSON Schema in metadataProperties." + type.key + ": " + schemaNode, e); + } + schemas.put(type, schema); + final JsonNode propertiesNode = schemaNode.get("properties"); + if (propertiesNode != null && propertiesNode.isObject()) { + final ImmutableSet.Builder names = ImmutableSet.builder(); + propertiesNode.fieldNames().forEachRemaining(names::add); + declaredProperties.put(type, names.build()); + } + } + + /** + * Validates the specified {@code properties} against the schema declared for the {@link ResourceType} + * and returns the properties to store. Undeclared properties are dropped rather than rejected. + * {@code null} is returned if there is nothing to store. + * + * @throws IllegalArgumentException if the declared properties do not conform to the schema + */ + @Nullable + public JsonNode validate(ResourceType type, @Nullable JsonNode properties) { + if (properties != null && properties.isNull()) { + // An explicit JSON null is equivalent to an absent field. + properties = null; + } + final JsonSchema schema = schemas.get(type); + if (schema == null) { + // No schema is declared for the resource type; ignore all properties. + return null; + } + if (properties != null && !properties.isObject()) { + throw new IllegalArgumentException( + "properties must be a JSON object: " + properties.getNodeType()); + } + + final Set declared = declaredProperties.get(type); + final ObjectNode filtered; + if (properties == null) { + filtered = JsonNodeFactory.instance.objectNode(); + } else if (declared == null) { + // The schema declares no top-level "properties" keyword; validate the object as is. + filtered = properties.deepCopy(); + } else { + filtered = JsonNodeFactory.instance.objectNode(); + for (String name : declared) { + final JsonNode value = properties.get(name); + if (value != null) { + filtered.set(name, value); + } + } + } + + final Set messages = schema.validate(filtered); + if (!messages.isEmpty()) { + throw new IllegalArgumentException( + "properties do not conform to the schema of metadataProperties." + type.key + ": " + + messages.stream().map(ValidationMessage::getMessage) + .collect(Collectors.joining(", "))); + } + return filtered.isEmpty() ? null : filtered; + } +} diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/DirectoryBasedStorageManager.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/DirectoryBasedStorageManager.java index 9e903349d..99a17be85 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/DirectoryBasedStorageManager.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/DirectoryBasedStorageManager.java @@ -43,6 +43,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.fasterxml.jackson.databind.JsonNode; import com.google.common.collect.ImmutableMap; import com.linecorp.centraldogma.common.Author; @@ -169,6 +170,11 @@ protected boolean replaceChild(String name, T oldChild, T newChild) { protected abstract T createChild( File childDir, Author author, long creationTimeMillis, boolean encrypt) throws Exception; + protected T createChild(File childDir, Author author, long creationTimeMillis, boolean encrypt, + @Nullable JsonNode properties) throws Exception { + return createChild(childDir, author, creationTimeMillis, encrypt); + } + private void closeChild(String name, T child, Supplier failureCauseSupplier) { closeChild(new File(rootDir, name), child, failureCauseSupplier); } @@ -213,13 +219,18 @@ public T get(String name) { @Override public T create(String name, long creationTimeMillis, Author author, boolean encrypt) { + return create(name, creationTimeMillis, author, encrypt, null); + } + + public T create(String name, long creationTimeMillis, Author author, boolean encrypt, + @Nullable JsonNode properties) { ensureOpen(); requireNonNull(author, "author"); validateChildName(name); final AtomicBoolean created = new AtomicBoolean(); final T child = children.computeIfAbsent(name, n -> { - final T c = create0(author, n, creationTimeMillis, encrypt); + final T c = create0(author, n, creationTimeMillis, encrypt, properties); created.set(true); return c; }); @@ -231,7 +242,8 @@ public T create(String name, long creationTimeMillis, Author author, boolean enc } } - private T create0(Author author, String name, long creationTimeMillis, boolean encrypt) { + private T create0(Author author, String name, long creationTimeMillis, boolean encrypt, + @Nullable JsonNode properties) { if (new File(rootDir, name + SUFFIX_REMOVED).exists()) { throw newStorageExistsException(name + " (removed)"); } @@ -239,7 +251,7 @@ private T create0(Author author, String name, long creationTimeMillis, boolean e final File f = new File(rootDir, name); boolean success = false; try { - final T newChild = createChild(f, author, creationTimeMillis, encrypt); + final T newChild = createChild(f, author, creationTimeMillis, encrypt, properties); success = true; return newChild; } catch (RuntimeException e) { diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/project/DefaultProject.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/project/DefaultProject.java index 2d2019645..944cefc05 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/project/DefaultProject.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/project/DefaultProject.java @@ -137,7 +137,7 @@ public class DefaultProject implements Project { DefaultProject(File rootDir, Executor repositoryWorker, Executor purgeWorker, long creationTimeMillis, Author author, @Nullable RepositoryCache cache, EncryptionStorageManager encryptionStorageManager, boolean encryptDogmaRepo, - Map> trustedHostKeys) { + Map> trustedHostKeys, @Nullable JsonNode properties) { requireNonNull(rootDir, "rootDir"); requireNonNull(repositoryWorker, "repositoryWorker"); requireNonNull(encryptionStorageManager, "encryptionStorageManager"); @@ -154,7 +154,7 @@ public class DefaultProject implements Project { try { createReservedRepos(creationTimeMillis, encryptDogmaRepo); if (!name.equals(INTERNAL_PROJECT_DOGMA)) { - initializeMetadata(creationTimeMillis, author); + initializeMetadata(creationTimeMillis, author, properties); attachMetadataListener(); metaRepo = new DefaultMetaRepository(repos.get(REPO_DOGMA), trustedHostKeys); registerMigrationCallback(); @@ -205,7 +205,8 @@ public ProjectMetadata metadata() { return projectMetadata; } - private void initializeMetadata(long creationTimeMillis, Author author) { + private void initializeMetadata(long creationTimeMillis, Author author, + @Nullable JsonNode properties) { // Do not generate a metadata file for internal projects. if (name.equals(INTERNAL_PROJECT_DOGMA)) { return; @@ -237,7 +238,8 @@ private void initializeMetadata(long creationTimeMillis, Author author) { members, null, appIds, - userAndTimestamp, null); + userAndTimestamp, null, + properties); final CommitResult result = dogmaRepo.commit(headRev, creationTimeMillis, Author.SYSTEM, "Initialize metadata", "", diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/project/DefaultProjectManager.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/project/DefaultProjectManager.java index 7e231dd02..9e72c488f 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/project/DefaultProjectManager.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/project/DefaultProjectManager.java @@ -32,6 +32,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.fasterxml.jackson.databind.JsonNode; import com.google.common.collect.ImmutableMap; import com.linecorp.centraldogma.common.Author; @@ -89,9 +90,16 @@ protected Project openChild(File childDir) throws Exception { @Override protected Project createChild( File childDir, Author author, long creationTimeMillis, boolean encrypt) throws Exception { + return createChild(childDir, author, creationTimeMillis, encrypt, null); + } + + @Override + protected Project createChild( + File childDir, Author author, long creationTimeMillis, boolean encrypt, + @Nullable JsonNode properties) throws Exception { return new DefaultProject(childDir, repositoryWorker, purgeWorker(), creationTimeMillis, author, cache, encryptionStorageManager(), encrypt, - trustedHostKeys); + trustedHostKeys, properties); } @Override diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/project/ProjectApiManager.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/project/ProjectApiManager.java index 7e72305c4..d61cb66c7 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/project/ProjectApiManager.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/storage/project/ProjectApiManager.java @@ -27,6 +27,8 @@ import org.jspecify.annotations.Nullable; +import com.fasterxml.jackson.databind.JsonNode; + import com.linecorp.centraldogma.common.Author; import com.linecorp.centraldogma.common.PermissionException; import com.linecorp.centraldogma.common.Revision; @@ -101,10 +103,11 @@ public Map listRemovedProjects() { return projectManager.listRemoved(); } - public CompletableFuture createProject(String projectName, Author author) { + public CompletableFuture createProject(String projectName, Author author, + @Nullable JsonNode properties) { checkInternalProject(projectName, "create"); if (!encryptionStorageManager.enabled()) { - return commandExecutor.execute(Command.createProject(author, projectName)); + return commandExecutor.execute(Command.createProject(author, projectName, null, properties)); } return encryptionStorageManager.generateWdek() .thenCompose(wdek -> { @@ -112,7 +115,8 @@ public CompletableFuture createProject(String projectName, Author author) wdek, 1, encryptionStorageManager.kekId(), projectName, Project.REPO_DOGMA); return commandExecutor.execute( - Command.createProject(author, projectName, wdekDetails)); + Command.createProject(author, projectName, wdekDetails, + properties)); }) .exceptionally(cause -> { throw new EncryptionStorageException( diff --git a/server/src/main/java/com/linecorp/centraldogma/server/internal/thrift/CentralDogmaServiceImpl.java b/server/src/main/java/com/linecorp/centraldogma/server/internal/thrift/CentralDogmaServiceImpl.java index 831d5d845..bca100c1a 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/internal/thrift/CentralDogmaServiceImpl.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/internal/thrift/CentralDogmaServiceImpl.java @@ -126,7 +126,7 @@ private static void handleAsVoidResult(CompletableFuture future, AsyncMethodC public void createProject(String name, AsyncMethodCallback resultHandler) { validateProjectName(name, "name", false); // ProjectInitializingCommandExecutor initializes a metadata for the specified project. - handle(projectApiManager.createProject(name, SYSTEM), resultHandler); + handle(projectApiManager.createProject(name, SYSTEM, null), resultHandler); } @Override diff --git a/server/src/main/java/com/linecorp/centraldogma/server/metadata/AbstractAppIdentity.java b/server/src/main/java/com/linecorp/centraldogma/server/metadata/AbstractAppIdentity.java index ce221eed7..adfda3056 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/metadata/AbstractAppIdentity.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/metadata/AbstractAppIdentity.java @@ -20,6 +20,7 @@ import org.jspecify.annotations.Nullable; +import com.fasterxml.jackson.databind.JsonNode; import com.google.common.base.MoreObjects; import com.google.common.base.MoreObjects.ToStringHelper; import com.google.common.base.Objects; @@ -40,10 +41,13 @@ abstract class AbstractAppIdentity implements AppIdentity { private final UserAndTimestamp deactivation; @Nullable private final UserAndTimestamp deletion; + @Nullable + private final JsonNode properties; AbstractAppIdentity(String appId, AppIdentityType type, boolean isSystemAdmin, boolean allowGuestAccess, UserAndTimestamp creation, - @Nullable UserAndTimestamp deactivation, @Nullable UserAndTimestamp deletion) { + @Nullable UserAndTimestamp deactivation, @Nullable UserAndTimestamp deletion, + @Nullable JsonNode properties) { this.appId = Util.validateFileName(appId, "appId"); this.type = requireNonNull(type, "type"); this.isSystemAdmin = isSystemAdmin; @@ -51,6 +55,8 @@ abstract class AbstractAppIdentity implements AppIdentity { this.creation = requireNonNull(creation, "creation"); this.deactivation = deactivation; this.deletion = deletion; + // Copy so that a later mutation of the argument cannot change this instance. + this.properties = properties != null ? properties.deepCopy() : null; } @Override @@ -95,6 +101,12 @@ public UserAndTimestamp deletion() { return deletion; } + @Nullable + @Override + public JsonNode properties() { + return properties; + } + @Override public boolean equals(Object o) { if (this == o) { @@ -112,12 +124,14 @@ public boolean equals(Object o) { allowGuestAccess == that.allowGuestAccess && creation.equals(that.creation) && Objects.equal(deactivation, that.deactivation) && - Objects.equal(deletion, that.deletion); + Objects.equal(deletion, that.deletion) && + Objects.equal(properties, that.properties); } @Override public int hashCode() { - return Objects.hashCode(appId, type, isSystemAdmin, allowGuestAccess, creation, deactivation, deletion); + return Objects.hashCode(appId, type, isSystemAdmin, allowGuestAccess, creation, deactivation, deletion, + properties); } @Override @@ -129,7 +143,8 @@ public final String toString() { .add("allowGuestAccess", allowGuestAccess()) .add("creation", creation()) .add("deactivation", deactivation()) - .add("deletion", deletion()); + .add("deletion", deletion()) + .add("properties", properties()); addProperties(helper); return helper.toString(); } diff --git a/server/src/main/java/com/linecorp/centraldogma/server/metadata/AppIdentity.java b/server/src/main/java/com/linecorp/centraldogma/server/metadata/AppIdentity.java index f31da878d..667936138 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/metadata/AppIdentity.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/metadata/AppIdentity.java @@ -21,6 +21,7 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; /** @@ -75,6 +76,13 @@ public interface AppIdentity extends Identifiable { @JsonProperty UserAndTimestamp deletion(); + /** + * Returns the additional properties of this application identity. + */ + @Nullable + @JsonProperty + JsonNode properties(); + /** * Returns whether this application identity is active. */ diff --git a/server/src/main/java/com/linecorp/centraldogma/server/metadata/AppIdentityDeserializer.java b/server/src/main/java/com/linecorp/centraldogma/server/metadata/AppIdentityDeserializer.java index ed2b2b335..a2d2cff6c 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/metadata/AppIdentityDeserializer.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/metadata/AppIdentityDeserializer.java @@ -80,8 +80,10 @@ private static Token deserializeToken(JsonNode node) { final UserAndTimestamp creation = deserializeUserAndTimestamp(getRequiredNode(node, "creation")); final UserAndTimestamp deactivation = deserializeOptionalUserAndTimestamp(node, "deactivation"); final UserAndTimestamp deletion = deserializeOptionalUserAndTimestamp(node, "deletion"); + final JsonNode properties = getOptionalObject(node, "properties"); - return new Token(appId, secret, systemAdmin, allowGuestAccess, creation, deactivation, deletion); + return new Token(appId, secret, systemAdmin, allowGuestAccess, creation, deactivation, deletion, + properties); } private static CertificateAppIdentity deserializeCertificate(JsonNode node) { @@ -92,9 +94,10 @@ private static CertificateAppIdentity deserializeCertificate(JsonNode node) { final UserAndTimestamp creation = deserializeUserAndTimestamp(getRequiredNode(node, "creation")); final UserAndTimestamp deactivation = deserializeOptionalUserAndTimestamp(node, "deactivation"); final UserAndTimestamp deletion = deserializeOptionalUserAndTimestamp(node, "deletion"); + final JsonNode properties = getOptionalObject(node, "properties"); return new CertificateAppIdentity(appId, certificateId, systemAdmin, allowGuestAccess, - creation, deactivation, deletion); + creation, deactivation, deletion, properties); } private static UserAndTimestamp deserializeUserAndTimestamp(JsonNode node) { @@ -153,4 +156,16 @@ private static Boolean getOptionalBoolean(JsonNode parent, String fieldName) { } return node.asBoolean(); } + + @Nullable + private static JsonNode getOptionalObject(JsonNode parent, String fieldName) { + final JsonNode node = parent.get(fieldName); + if (node == null || node.isNull()) { + return null; + } + if (!node.isObject()) { + throw new IllegalArgumentException("Field '" + fieldName + "' must be an object"); + } + return node; + } } diff --git a/server/src/main/java/com/linecorp/centraldogma/server/metadata/AppIdentityService.java b/server/src/main/java/com/linecorp/centraldogma/server/metadata/AppIdentityService.java index db742735c..ef96bede6 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/metadata/AppIdentityService.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/metadata/AppIdentityService.java @@ -33,6 +33,8 @@ import java.util.UUID; import java.util.concurrent.CompletableFuture; +import org.jspecify.annotations.Nullable; + import com.fasterxml.jackson.core.JsonPointer; import com.fasterxml.jackson.databind.JsonNode; import com.google.common.collect.ImmutableMap; @@ -70,26 +72,27 @@ AppIdentityRegistry getAppIdentityRegistry() { } CompletableFuture createToken(Author author, String appId) { - return createToken(author, appId, false); + return createToken(author, appId, false, null); } - CompletableFuture createToken(Author author, String appId, boolean isSystemAdmin) { - return createToken(author, appId, SECRET_PREFIX + UUID.randomUUID(), isSystemAdmin); + CompletableFuture createToken(Author author, String appId, boolean isSystemAdmin, + @Nullable JsonNode properties) { + return createToken(author, appId, SECRET_PREFIX + UUID.randomUUID(), isSystemAdmin, properties); } CompletableFuture createToken(Author author, String appId, String secret) { - return createToken(author, appId, secret, false); + return createToken(author, appId, secret, false, null); } CompletableFuture createToken(Author author, String appId, String secret, - boolean isSystemAdmin) { + boolean isSystemAdmin, @Nullable JsonNode properties) { requireNonNull(author, "author"); requireNonNull(appId, "appId"); requireNonNull(secret, "secret"); validateSecret(secret); final Token newToken = new Token(appId, secret, isSystemAdmin, isSystemAdmin, - UserAndTimestamp.of(author)); + UserAndTimestamp.of(author), properties); final AppIdentityRegistryTransformer transformer = new AppIdentityRegistryTransformer( (headRevision, tokens) -> { if (tokens.appIds().containsKey(newToken.id())) { @@ -137,7 +140,8 @@ CompletableFuture destroyToken(Author author, String appId) { final Token newToken = new Token( appIdentity.appId(), secret, appIdentity.isSystemAdmin(), appIdentity.allowGuestAccess(), - appIdentity.creation(), appIdentity.deactivation(), userAndTimestamp); + appIdentity.creation(), appIdentity.deactivation(), userAndTimestamp, + appIdentity.properties()); final Map newSecrets = removeFromMap(registry.secrets(), secret); return new AppIdentityRegistry(updateMap(registry.appIds(), appId, newToken), newSecrets, registry.certificateIds()); @@ -183,7 +187,8 @@ CompletableFuture activateToken(Author author, String appId) { final Map newSecrets = addToMap(registry.secrets(), secret, appId); // The key is secret not appId. final Token newToken = new Token(appIdentity.appId(), secret, appIdentity.isSystemAdmin(), - appIdentity.allowGuestAccess(), appIdentity.creation()); + appIdentity.allowGuestAccess(), appIdentity.creation(), + appIdentity.properties()); return new AppIdentityRegistry(updateMap(registry.appIds(), appId, newToken), newSecrets, registry.certificateIds()); }); @@ -206,7 +211,8 @@ CompletableFuture deactivateToken(Author author, String appId) { assert secret != null; final Token newToken = new Token(appIdentity.appId(), secret, appIdentity.isSystemAdmin(), appIdentity.allowGuestAccess(), - appIdentity.creation(), userAndTimestamp, null); + appIdentity.creation(), userAndTimestamp, null, + appIdentity.properties()); final Map newAppIds = updateMap(registry.appIds(), appId, newToken); final Map newSecrets = removeFromMap(registry.secrets(), secret); // Note that the key is secret not appId. @@ -280,7 +286,7 @@ private static void throwIfInvalidType(String appId, AppIdentity appIdentity, } CompletableFuture createCertificate(Author author, String appId, String certificateId, - boolean isSystemAdmin) { + boolean isSystemAdmin, @Nullable JsonNode properties) { requireNonNull(author, "author"); requireNonNull(appId, "appId"); checkArgument(!isNullOrEmpty(certificateId), "certificateId must not be null or empty"); @@ -288,7 +294,7 @@ CompletableFuture createCertificate(Author author, String appId, Strin // Does not allow guest access for non admin certificate. final CertificateAppIdentity certificate = new CertificateAppIdentity(appId, certificateId, isSystemAdmin, isSystemAdmin, - UserAndTimestamp.of(author)); + UserAndTimestamp.of(author), properties); final JsonPointer appIdPath = JsonPointer.compile("/appIds" + encodeSegment(certificate.appId())); final JsonPointer certificateIdPath = JsonPointer.compile("/certificateIds" + encodeSegment(certificateId)); @@ -320,7 +326,8 @@ CompletableFuture destroyCertificate(Author author, String appId) { final CertificateAppIdentity newCertificate = new CertificateAppIdentity( appIdentity.appId(), ((CertificateAppIdentity) appIdentity).certificateId(), appIdentity.isSystemAdmin(), appIdentity.allowGuestAccess(), - appIdentity.creation(), appIdentity.deactivation(), userAndTimestamp); + appIdentity.creation(), appIdentity.deactivation(), userAndTimestamp, + appIdentity.properties()); final String certificateId = ((CertificateAppIdentity) appIdentity).certificateId(); final Map newCertificateIds = removeFromMap(registry.certificateIds(), certificateId); @@ -348,7 +355,8 @@ CompletableFuture activateCertificate(Author author, String appId) { certificate.certificateId(), certificate.isSystemAdmin(), certificate.allowGuestAccess(), - certificate.creation()); + certificate.creation(), + certificate.properties()); final Map newCertificateIds = addToMap(registry.certificateIds(), certificate.certificateId(), appId); return new AppIdentityRegistry(updateMap(registry.appIds(), appId, newCertificate), @@ -376,7 +384,8 @@ CompletableFuture deactivateCertificate(Author author, String appId) { appIdentity.isSystemAdmin(), appIdentity.allowGuestAccess(), appIdentity.creation(), - userAndTimestamp, null); + userAndTimestamp, null, + appIdentity.properties()); final Map newAppIds = updateMap(registry.appIds(), appId, newCertificate); final Map newCertificateIds = diff --git a/server/src/main/java/com/linecorp/centraldogma/server/metadata/CertificateAppIdentity.java b/server/src/main/java/com/linecorp/centraldogma/server/metadata/CertificateAppIdentity.java index 1d772c129..cd932ee7f 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/metadata/CertificateAppIdentity.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/metadata/CertificateAppIdentity.java @@ -26,6 +26,7 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude.Include; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; import com.google.common.base.MoreObjects.ToStringHelper; import com.google.common.base.Objects; @@ -39,8 +40,8 @@ public final class CertificateAppIdentity extends AbstractAppIdentity { private final String certificateId; CertificateAppIdentity(String appId, String certificateId, boolean isSystemAdmin, boolean allowGuestAccess, - UserAndTimestamp creation) { - this(appId, certificateId, isSystemAdmin, allowGuestAccess, creation, null, null); + UserAndTimestamp creation, @Nullable JsonNode properties) { + this(appId, certificateId, isSystemAdmin, allowGuestAccess, creation, null, null, properties); } /** @@ -53,10 +54,11 @@ public CertificateAppIdentity(@JsonProperty("appId") String appId, @JsonProperty("allowGuestAccess") @Nullable Boolean allowGuestAccess, @JsonProperty("creation") UserAndTimestamp creation, @JsonProperty("deactivation") @Nullable UserAndTimestamp deactivation, - @JsonProperty("deletion") @Nullable UserAndTimestamp deletion) { + @JsonProperty("deletion") @Nullable UserAndTimestamp deletion, + @JsonProperty("properties") @Nullable JsonNode properties) { super(appId, AppIdentityType.CERTIFICATE, isSystemAdmin, firstNonNull(allowGuestAccess, false), // Disallow guest access by default for certificate. - requireNonNull(creation, "creation"), deactivation, deletion); + requireNonNull(creation, "creation"), deactivation, deletion, properties); this.certificateId = requireNonNull(certificateId, "certificateId"); } @@ -74,7 +76,7 @@ public CertificateAppIdentity withSystemAdmin(boolean isSystemAdmin) { return this; } return new CertificateAppIdentity(appId(), certificateId, isSystemAdmin, allowGuestAccess(), - creation(), deactivation(), deletion()); + creation(), deactivation(), deletion(), properties()); } @Override diff --git a/server/src/main/java/com/linecorp/centraldogma/server/metadata/MetadataService.java b/server/src/main/java/com/linecorp/centraldogma/server/metadata/MetadataService.java index e1a108be2..246b64d6a 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/metadata/MetadataService.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/metadata/MetadataService.java @@ -32,6 +32,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.fasterxml.jackson.databind.JsonNode; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableMap.Builder; @@ -187,7 +188,8 @@ public CompletableFuture removeProject(Author author, String projectNa null, projectMetadata.appIds(), projectMetadata.creation(), - UserAndTimestamp.of(author)); + UserAndTimestamp.of(author), + projectMetadata.properties()); }); return metadataRepo.push(projectName, Project.REPO_DOGMA, author, "Remove the project: " + projectName, transformer); @@ -211,7 +213,8 @@ public CompletableFuture restoreProject(Author author, String projectN null, projectMetadata.appIds(), projectMetadata.creation(), - null); + null, + projectMetadata.properties()); }); return metadataRepo.push(projectName, Project.REPO_DOGMA, author, "Restore the project: " + projectName, transformer); @@ -260,7 +263,8 @@ public CompletableFuture addMember(Author author, String projectName, null, projectMetadata.appIds(), projectMetadata.creation(), - projectMetadata.removal()); + projectMetadata.removal(), + projectMetadata.properties()); }); return metadataRepo.push(projectName, Project.REPO_DOGMA, author, commitSummary, transformer); } @@ -291,7 +295,8 @@ public CompletableFuture removeMember(Author author, String projectNam null, projectMetadata.appIds(), projectMetadata.creation(), - projectMetadata.removal()); + projectMetadata.removal(), + projectMetadata.properties()); }); return metadataRepo.push(projectName, Project.REPO_DOGMA, author, commitSummary, transformer); } @@ -312,7 +317,8 @@ private static ImmutableMap removeMemberFromReposito newRoles, repositoryMetadata.creation(), repositoryMetadata.removal(), - repositoryMetadata.status())); + repositoryMetadata.status(), + repositoryMetadata.properties())); } else { reposBuilder.put(entry); } @@ -356,7 +362,8 @@ public CompletableFuture updateMemberRole(Author author, String projec null, projectMetadata.appIds(), projectMetadata.creation(), - projectMetadata.removal()); + projectMetadata.removal(), + projectMetadata.properties()); }); return metadataRepo.push(projectName, Project.REPO_DOGMA, author, commitSummary, transformer); } @@ -424,7 +431,8 @@ public CompletableFuture addRepo(Author author, String projectName, null, projectMetadata.appIds(), projectMetadata.creation(), - projectMetadata.removal()); + projectMetadata.removal(), + projectMetadata.properties()); }); return metadataRepo.push(projectName, Project.REPO_DOGMA, author, commitSummary, transformer); } @@ -451,7 +459,8 @@ public CompletableFuture removeRepo(Author author, String projectName, repositoryMetadata.roles(), repositoryMetadata.creation(), UserAndTimestamp.of(author), - repositoryMetadata.status()); + repositoryMetadata.status(), + repositoryMetadata.properties()); }); return metadataRepo.push(projectName, Project.REPO_DOGMA, author, commitSummary, transformer); } @@ -482,7 +491,8 @@ public CompletableFuture purgeRepo(Author author, String projectName, null, projectMetadata.appIds(), projectMetadata.creation(), - projectMetadata.removal()); + projectMetadata.removal(), + projectMetadata.properties()); }); return metadataRepo.push(projectName, Project.REPO_DOGMA, author, commitSummary, transformer); } @@ -508,7 +518,8 @@ public CompletableFuture restoreRepo(Author author, String projectName repositoryMetadata.roles(), repositoryMetadata.creation(), null, - repositoryMetadata.status()); + repositoryMetadata.status(), + repositoryMetadata.properties()); }); return metadataRepo.push(projectName, Project.REPO_DOGMA, author, commitSummary, transformer); } @@ -539,7 +550,8 @@ public CompletableFuture updateRepositoryProjectRoles(Author author, newRoles, repositoryMetadata.creation(), repositoryMetadata.removal(), - repositoryMetadata.status()); + repositoryMetadata.status(), + repositoryMetadata.properties()); }); return metadataRepo.push(projectName, Project.REPO_DOGMA, author, commitSummary, transformer); } @@ -576,7 +588,8 @@ public CompletableFuture addAppIdentity(Author author, String projectN null, newAppIds, projectMetadata.creation(), - projectMetadata.removal()); + projectMetadata.removal(), + projectMetadata.properties()); }); return metadataRepo.push(projectName, Project.REPO_DOGMA, author, commitSummary, transformer); } @@ -624,7 +637,8 @@ CompletableFuture removeAppIdentityFromProject(Author author, String p null, newAppIds, projectMetadata.creation(), - projectMetadata.removal()); + projectMetadata.removal(), + projectMetadata.properties()); }); return metadataRepo.push(projectName, Project.REPO_DOGMA, author, commitSummary, transformer); } @@ -643,7 +657,8 @@ private static ImmutableMap removeAppIdentityFromRep newRoles, repositoryMetadata.creation(), repositoryMetadata.removal(), - repositoryMetadata.status())); + repositoryMetadata.status(), + repositoryMetadata.properties())); } else { builder.put(entry); } @@ -688,7 +703,8 @@ public CompletableFuture updateAppIdentityRole( null, newAppIds, projectMetadata.creation(), - projectMetadata.removal()); + projectMetadata.removal(), + projectMetadata.properties()); }); return metadataRepo.push(projectName, Project.REPO_DOGMA, author, commitSummary, transformer); } @@ -726,7 +742,8 @@ public CompletableFuture addUserRepositoryRole(Author author, String p newRoles, repositoryMetadata.creation(), repositoryMetadata.removal(), - repositoryMetadata.status()); + repositoryMetadata.status(), + repositoryMetadata.properties()); }); return metadataRepo.push(projectName, Project.REPO_DOGMA, author, commitSummary, transformer); }); @@ -757,7 +774,8 @@ public CompletableFuture removeUserRepositoryRole(Author author, Strin newRoles, repositoryMetadata.creation(), repositoryMetadata.removal(), - repositoryMetadata.status()); + repositoryMetadata.status(), + repositoryMetadata.properties()); }); final String commitSummary = "Remove repository role of the '" + memberId + "' from '" + projectName + '/' + repoName + '\''; @@ -799,7 +817,8 @@ public CompletableFuture updateUserRepositoryRole(Author author, Strin newRoles, repositoryMetadata.creation(), repositoryMetadata.removal(), - repositoryMetadata.status()); + repositoryMetadata.status(), + repositoryMetadata.properties()); }); final String commitSummary = "Update repository role of the '" + memberId + "' as '" + role + "' for '" + projectName + '/' + repoName + '\''; @@ -839,7 +858,8 @@ public CompletableFuture addAppIdentityRepositoryRole(Author author, S newRoles, repositoryMetadata.creation(), repositoryMetadata.removal(), - repositoryMetadata.status()); + repositoryMetadata.status(), + repositoryMetadata.properties()); }); return metadataRepo.push(projectName, Project.REPO_DOGMA, author, commitSummary, transformer); }); @@ -871,7 +891,8 @@ public CompletableFuture removeAppIdentityRepositoryRole(Author author newRoles, repositoryMetadata.creation(), repositoryMetadata.removal(), - repositoryMetadata.status()); + repositoryMetadata.status(), + repositoryMetadata.properties()); }); final String commitSummary = "Remove repository role of the app identity '" + appId + "' from '" + projectName + '/' + repoName + '\''; @@ -915,7 +936,8 @@ public CompletableFuture updateAppIdentityRepositoryRole(Author author newRoles, repositoryMetadata.creation(), repositoryMetadata.removal(), - repositoryMetadata.status()); + repositoryMetadata.status(), + repositoryMetadata.properties()); }); final String commitSummary = "Update repository role of the app identity '" + appId + "' for '" + projectName + '/' + repoName + '\''; @@ -1092,8 +1114,9 @@ public CompletableFuture createToken(Author author, String appId) { * Creates a new {@link Token} with the specified {@code appId}, {@code isSystemAdmin} and an auto-generated * secret. */ - public CompletableFuture createToken(Author author, String appId, boolean isSystemAdmin) { - return appIdentityService.createToken(author, appId, isSystemAdmin); + public CompletableFuture createToken(Author author, String appId, boolean isSystemAdmin, + @Nullable JsonNode properties) { + return appIdentityService.createToken(author, appId, isSystemAdmin, properties); } /** @@ -1107,8 +1130,8 @@ public CompletableFuture createToken(Author author, String appId, Stri * Creates a new {@link Token} with the specified {@code appId}, {@code secret} and {@code isSystemAdmin}. */ public CompletableFuture createToken(Author author, String appId, String secret, - boolean isSystemAdmin) { - return appIdentityService.createToken(author, appId, secret, isSystemAdmin); + boolean isSystemAdmin, @Nullable JsonNode properties) { + return appIdentityService.createToken(author, appId, secret, isSystemAdmin, properties); } /** @@ -1250,7 +1273,8 @@ public CompletableFuture updateRepositoryStatus( null, projectMetadata.appIds(), projectMetadata.creation(), - projectMetadata.removal()); + projectMetadata.removal(), + projectMetadata.properties()); }); } else { transformer = new RepositoryMetadataTransformer( @@ -1261,7 +1285,8 @@ public CompletableFuture updateRepositoryStatus( repositoryMetadata.roles(), repositoryMetadata.creation(), repositoryMetadata.removal(), - repositoryStatus); + repositoryStatus, + repositoryMetadata.properties()); }); } @@ -1284,8 +1309,9 @@ private static void throwIfRedundant(RepositoryStatus repositoryStatus, Revision * {@code certificateId}. */ public CompletableFuture createCertificate(Author author, String appId, String certificateId, - boolean isSystemAdmin) { - return appIdentityService.createCertificate(author, appId, certificateId, isSystemAdmin); + boolean isSystemAdmin, + @Nullable JsonNode properties) { + return appIdentityService.createCertificate(author, appId, certificateId, isSystemAdmin, properties); } /** diff --git a/server/src/main/java/com/linecorp/centraldogma/server/metadata/ProjectMetadata.java b/server/src/main/java/com/linecorp/centraldogma/server/metadata/ProjectMetadata.java index 289f0d129..ac014269b 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/metadata/ProjectMetadata.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/metadata/ProjectMetadata.java @@ -30,6 +30,7 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude.Include; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; import com.google.common.base.MoreObjects; import com.google.common.collect.ImmutableMap; @@ -51,6 +52,7 @@ public class ProjectMetadata implements Identifiable, HasWeight { null, ImmutableMap.of(), new UserAndTimestamp(User.SYSTEM.id()), + null, null); /** @@ -84,6 +86,12 @@ public class ProjectMetadata implements Identifiable, HasWeight { @Nullable private final UserAndTimestamp removal; + /** + * The additional properties of this project. + */ + @Nullable + private final JsonNode properties; + /** * Creates a new instance. */ @@ -94,7 +102,8 @@ public ProjectMetadata(@JsonProperty("name") String name, @JsonProperty("tokens") @Nullable Map tokens, @JsonProperty("appIds") @Nullable Map appIds, @JsonProperty("creation") UserAndTimestamp creation, - @JsonProperty("removal") @Nullable UserAndTimestamp removal) { + @JsonProperty("removal") @Nullable UserAndTimestamp removal, + @JsonProperty("properties") @Nullable JsonNode properties) { this.name = requireNonNull(name, "name"); this.repos = ImmutableMap.copyOf(requireNonNull(repos, "repos")); this.members = ImmutableMap.copyOf(requireNonNull(members, "members")); @@ -110,6 +119,8 @@ public ProjectMetadata(@JsonProperty("name") String name, this.creation = requireNonNull(creation, "creation"); this.removal = removal; + // Copy so that a later mutation of the argument cannot change this instance. + this.properties = properties != null ? properties.deepCopy() : null; } @Override @@ -166,6 +177,15 @@ public UserAndTimestamp removal() { return removal; } + /** + * Returns the additional properties of this project. + */ + @Nullable + @JsonProperty + public JsonNode properties() { + return properties; + } + /** * Returns the {@link RepositoryMetadata} of the specified repository in this project. */ @@ -227,6 +247,9 @@ public int weight() { for (AppIdentityRegistration appIdentityRegistration : appIds.values()) { weight += appIdentityRegistration.weight(); } + if (properties != null) { + weight += properties.toString().length(); + } return weight; } @@ -245,12 +268,13 @@ public boolean equals(Object o) { members.equals(that.members) && appIds.equals(that.appIds) && creation.equals(that.creation) && - Objects.equals(removal, that.removal); + Objects.equals(removal, that.removal) && + Objects.equals(properties, that.properties); } @Override public int hashCode() { - return Objects.hash(name, repos, members, appIds, creation, removal); + return Objects.hash(name, repos, members, appIds, creation, removal, properties); } @Override @@ -262,6 +286,7 @@ public String toString() { .add("appIds", appIds()) .add("creation", creation()) .add("removal", removal()) + .add("properties", properties()) .toString(); } @@ -281,6 +306,7 @@ public ProjectMetadata withoutDogmaRepo() { null, appIds(), creation(), - removal()); + removal(), + properties()); } } diff --git a/server/src/main/java/com/linecorp/centraldogma/server/metadata/RepositoryMetadata.java b/server/src/main/java/com/linecorp/centraldogma/server/metadata/RepositoryMetadata.java index 64ae50847..8a4e18fc7 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/metadata/RepositoryMetadata.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/metadata/RepositoryMetadata.java @@ -28,6 +28,7 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude.Include; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; import com.google.common.base.MoreObjects; import com.google.common.collect.ImmutableMap; @@ -54,13 +55,14 @@ public static RepositoryMetadata of(String name, UserAndTimestamp creation) { } /** - * Creates a new instance with default properties. + * Creates a new instance with the specified additional {@code properties}. */ - public static RepositoryMetadata of(String name, Roles roles, UserAndTimestamp creation) { + public static RepositoryMetadata of(String name, Roles roles, UserAndTimestamp creation, + @Nullable JsonNode properties) { requireNonNull(name, "name"); requireNonNull(roles, "roles"); requireNonNull(creation, "creation"); - return new RepositoryMetadata(name, roles, creation, null, RepositoryStatus.ACTIVE); + return new RepositoryMetadata(name, roles, creation, null, RepositoryStatus.ACTIVE, properties); } /** @@ -74,7 +76,7 @@ public static RepositoryMetadata of(String name, UserAndTimestamp creation, Proj * Creates a new instance for dogma repository. */ public static RepositoryMetadata ofDogma(RepositoryStatus repositoryStatus) { - return new RepositoryMetadata(Project.REPO_DOGMA, Roles.EMPTY, null, null, repositoryStatus); + return new RepositoryMetadata(Project.REPO_DOGMA, Roles.EMPTY, null, null, repositoryStatus, null); } /** @@ -98,13 +100,19 @@ public static RepositoryMetadata ofDogma(RepositoryStatus repositoryStatus) { private final RepositoryStatus repositoryStatus; + /** + * The additional properties of this repository. + */ + @Nullable + private final JsonNode properties; + /** * Creates a new instance. */ private RepositoryMetadata(String name, UserAndTimestamp creation, ProjectRoles projectRoles) { this(name, new Roles(requireNonNull(projectRoles, "projectRoles"), ImmutableMap.of(), null, ImmutableMap.of()), - creation, /* removal */ null, RepositoryStatus.ACTIVE); + creation, /* removal */ null, RepositoryStatus.ACTIVE, null); } /** @@ -115,7 +123,8 @@ public RepositoryMetadata(@JsonProperty("name") String name, @JsonProperty("roles") Roles roles, @JsonProperty("creation") @Nullable UserAndTimestamp creation, @JsonProperty("removal") @Nullable UserAndTimestamp removal, - @JsonProperty("status") @Nullable RepositoryStatus repositoryStatus) { + @JsonProperty("status") @Nullable RepositoryStatus repositoryStatus, + @JsonProperty("properties") @Nullable JsonNode properties) { this.name = requireNonNull(name, "name"); this.roles = requireNonNull(roles, "roles"); if (!Project.REPO_DOGMA.equals(name)) { @@ -124,6 +133,8 @@ public RepositoryMetadata(@JsonProperty("name") String name, this.creation = creation; this.removal = removal; this.repositoryStatus = firstNonNull(repositoryStatus, RepositoryStatus.ACTIVE); + // Copy so that a later mutation of the argument cannot change this instance. + this.properties = properties != null ? properties.deepCopy() : null; } @Override @@ -174,11 +185,23 @@ public RepositoryStatus status() { return repositoryStatus; } + /** + * Returns the additional properties of this repository. + */ + @Nullable + @JsonProperty + public JsonNode properties() { + return properties; + } + @Override public int weight() { int weight = 0; weight += name.length(); weight += roles.weight(); + if (properties != null) { + weight += properties.toString().length(); + } return weight; } @@ -196,12 +219,13 @@ public boolean equals(Object o) { roles.equals(that.roles) && Objects.equals(creation, that.creation) && Objects.equals(removal, that.removal) && - repositoryStatus == that.repositoryStatus; + repositoryStatus == that.repositoryStatus && + Objects.equals(properties, that.properties); } @Override public int hashCode() { - return Objects.hash(name, roles, creation, removal, repositoryStatus); + return Objects.hash(name, roles, creation, removal, repositoryStatus, properties); } @Override @@ -213,6 +237,7 @@ public String toString() { .add("creation", creation) .add("removal", removal) .add("repositoryStatus", repositoryStatus) + .add("properties", properties) .toString(); } } diff --git a/server/src/main/java/com/linecorp/centraldogma/server/metadata/RepositoryMetadataTransformer.java b/server/src/main/java/com/linecorp/centraldogma/server/metadata/RepositoryMetadataTransformer.java index 5a007ffc6..9dd13e4e9 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/metadata/RepositoryMetadataTransformer.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/metadata/RepositoryMetadataTransformer.java @@ -54,6 +54,7 @@ private static ProjectMetadata newProjectMetadata(ProjectMetadata projectMetadat null, projectMetadata.appIds(), projectMetadata.creation(), - projectMetadata.removal()); + projectMetadata.removal(), + projectMetadata.properties()); } } diff --git a/server/src/main/java/com/linecorp/centraldogma/server/metadata/Token.java b/server/src/main/java/com/linecorp/centraldogma/server/metadata/Token.java index e887b0c1c..fdfd3cb07 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/metadata/Token.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/metadata/Token.java @@ -22,7 +22,9 @@ import org.jspecify.annotations.Nullable; import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; import com.google.common.base.MoreObjects; import com.google.common.base.Objects; @@ -31,6 +33,7 @@ /** * Specifies details of an application token. */ +@JsonIgnoreProperties(ignoreUnknown = true) public final class Token extends AbstractAppIdentity { /** @@ -40,8 +43,9 @@ public final class Token extends AbstractAppIdentity { private final String secret; Token(String appId, String secret, boolean isSystemAdmin, boolean allowGuestAccess, - UserAndTimestamp creation) { - super(appId, AppIdentityType.TOKEN, isSystemAdmin, allowGuestAccess, creation, null, null); + UserAndTimestamp creation, @Nullable JsonNode properties) { + super(appId, AppIdentityType.TOKEN, isSystemAdmin, allowGuestAccess, creation, null, null, + properties); this.secret = Util.validateFileName(secret, "secret"); } @@ -55,20 +59,23 @@ public Token(@JsonProperty("appId") String appId, @JsonProperty("allowGuestAccess") @Nullable Boolean allowGuestAccess, @JsonProperty("creation") UserAndTimestamp creation, @JsonProperty("deactivation") @Nullable UserAndTimestamp deactivation, - @JsonProperty("deletion") @Nullable UserAndTimestamp deletion) { + @JsonProperty("deletion") @Nullable UserAndTimestamp deletion, + @JsonProperty("properties") @Nullable JsonNode properties) { super(appId, AppIdentityType.TOKEN, isSystemAdmin, // Allow guest access by default for backward compatibility. firstNonNull(allowGuestAccess, true), requireNonNull(creation, "creation"), deactivation, - deletion); + deletion, + properties); this.secret = Util.validateFileName(secret, "secret"); } private Token(String appId, boolean isSystemAdmin, boolean allowGuestAccess, UserAndTimestamp creation, - @Nullable UserAndTimestamp deactivation, @Nullable UserAndTimestamp deletion) { + @Nullable UserAndTimestamp deactivation, @Nullable UserAndTimestamp deletion, + @Nullable JsonNode properties) { super(appId, AppIdentityType.TOKEN, isSystemAdmin, allowGuestAccess, - requireNonNull(creation, "creation"), deactivation, deletion); + requireNonNull(creation, "creation"), deactivation, deletion, properties); secret = null; } @@ -94,7 +101,8 @@ public String secret() { * Returns a new {@link Token} instance without its secret. */ public Token withoutSecret() { - return new Token(id(), isSystemAdmin(), allowGuestAccess(), creation(), deactivation(), deletion()); + return new Token(id(), isSystemAdmin(), allowGuestAccess(), creation(), deactivation(), deletion(), + properties()); } /** @@ -109,7 +117,7 @@ public Token withSystemAdmin(boolean isSystemAdmin) { final String secret = secret(); assert secret != null; return new Token(id(), secret, isSystemAdmin, allowGuestAccess(), creation(), - deactivation(), deletion()); + deactivation(), deletion(), properties()); } @Override diff --git a/server/src/main/java/com/linecorp/centraldogma/server/storage/project/ProjectManager.java b/server/src/main/java/com/linecorp/centraldogma/server/storage/project/ProjectManager.java index bc54be787..f392ff4bf 100644 --- a/server/src/main/java/com/linecorp/centraldogma/server/storage/project/ProjectManager.java +++ b/server/src/main/java/com/linecorp/centraldogma/server/storage/project/ProjectManager.java @@ -16,9 +16,21 @@ package com.linecorp.centraldogma.server.storage.project; +import org.jspecify.annotations.Nullable; + +import com.fasterxml.jackson.databind.JsonNode; + +import com.linecorp.centraldogma.common.Author; import com.linecorp.centraldogma.server.storage.StorageManager; /** * A manager which manages {@link Project}s in the Central Dogma. */ -public interface ProjectManager extends StorageManager {} +public interface ProjectManager extends StorageManager { + + /** + * Creates a new {@link Project} with the specified metadata {@code properties}. + */ + Project create(String name, long creationTimeMillis, Author author, boolean encrypt, + @Nullable JsonNode properties); +} diff --git a/server/src/test/java/com/linecorp/centraldogma/server/MetadataPropertiesConfigTest.java b/server/src/test/java/com/linecorp/centraldogma/server/MetadataPropertiesConfigTest.java new file mode 100644 index 000000000..dc0208cc6 --- /dev/null +++ b/server/src/test/java/com/linecorp/centraldogma/server/MetadataPropertiesConfigTest.java @@ -0,0 +1,73 @@ +/* + * Copyright 2026 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.centraldogma.server; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +import com.linecorp.centraldogma.internal.Jackson; + +class MetadataPropertiesConfigTest { + + @Test + void parsesMetadataProperties() throws Exception { + final CentralDogmaConfig cfg = + Jackson.readValue("{\n" + + " \"dataDir\": \"./data\",\n" + + " \"ports\": [\n" + + " {\n" + + " \"localAddress\": {\n" + + " \"host\": \"*\",\n" + + " \"port\": 36462\n" + + " },\n" + + " \"protocols\": [ \"http\" ]\n" + + " }\n" + + " ],\n" + + " \"metadataProperties\": {\n" + + " \"project\": {\n" + + " \"type\": \"object\",\n" + + " \"properties\": { \"serviceId\": { \"type\": \"string\" } },\n" + + " \"required\": [ \"serviceId\" ]\n" + + " },\n" + + " \"repo\": { \"type\": \"object\" }\n" + + " }\n" + + '}', + CentralDogmaConfig.class); + final MetadataPropertiesConfig metadataProperties = cfg.metadataProperties(); + assertThat(metadataProperties).isNotNull(); + assertThat(metadataProperties.project().get("required").get(0).asText()).isEqualTo("serviceId"); + assertThat(metadataProperties.repo()).isEqualTo(Jackson.readTree("{ \"type\": \"object\" }")); + assertThat(metadataProperties.appIdentity()).isNull(); + } + + @Test + void nullWhenAbsent() throws Exception { + final CentralDogmaConfig cfg = + Jackson.readValue("{\n" + + " \"dataDir\": \"./data\",\n" + + " \"ports\": [\n" + + " {\n" + + " \"localAddress\": { \"host\": \"*\", \"port\": 36462 },\n" + + " \"protocols\": [ \"http\" ]\n" + + " }\n" + + " ]\n" + + '}', + CentralDogmaConfig.class); + assertThat(cfg.metadataProperties()).isNull(); + } +} diff --git a/server/src/test/java/com/linecorp/centraldogma/server/command/CreateProjectCommandTest.java b/server/src/test/java/com/linecorp/centraldogma/server/command/CreateProjectCommandTest.java index 1e3749c0c..09eb09bd1 100644 --- a/server/src/test/java/com/linecorp/centraldogma/server/command/CreateProjectCommandTest.java +++ b/server/src/test/java/com/linecorp/centraldogma/server/command/CreateProjectCommandTest.java @@ -28,7 +28,8 @@ class CreateProjectCommandTest { @Test void testJsonConversion() { - assertJsonConversion(new CreateProjectCommand(1234L, new Author("foo", "bar@baz.com"), "foo", null), + assertJsonConversion(new CreateProjectCommand(1234L, new Author("foo", "bar@baz.com"), "foo", null, + null), Command.class, '{' + " \"type\": \"CREATE_PROJECT\"," + diff --git a/server/src/test/java/com/linecorp/centraldogma/server/internal/admin/model/SerializationTest.java b/server/src/test/java/com/linecorp/centraldogma/server/internal/admin/model/SerializationTest.java index 2266816d5..2ddee8e3e 100644 --- a/server/src/test/java/com/linecorp/centraldogma/server/internal/admin/model/SerializationTest.java +++ b/server/src/test/java/com/linecorp/centraldogma/server/internal/admin/model/SerializationTest.java @@ -68,7 +68,7 @@ void testValidProject() throws IOException { final Member member = new Member(userLogin, ProjectRole.MEMBER, newCreationTag()); final RepositoryMetadata repositoryMetadata = RepositoryMetadata.of("sample", newCreationTag()); final Token token = new Token("testApp", "testSecret", false, true, newCreationTag(), null, - null); + null, null); final RepositoryMetadata dogmaRepo = RepositoryMetadata.ofDogma(RepositoryStatus.ACTIVE); final ProjectMetadata metadata = @@ -82,6 +82,7 @@ void testValidProject() throws IOException { ProjectRole.MEMBER, newCreationTag())), newCreationTag(), + null, null); assertThatJson(metadata) .isEqualTo("{\n" + @@ -208,7 +209,7 @@ void testRemovedProject() throws IOException { newCreationTag()); final RepositoryMetadata repositoryMetadata = RepositoryMetadata.of("sample", newCreationTag()); final Token token = new Token("testApp", "testSecret", false, true, newCreationTag(), null, - null); + null, null); final ProjectMetadata metadata = new ProjectMetadata("test", ImmutableMap.of(repositoryMetadata.name(), repositoryMetadata), @@ -219,7 +220,8 @@ void testRemovedProject() throws IOException { ProjectRole.MEMBER, newCreationTag())), newCreationTag(), - newRemovalTag()); + newRemovalTag(), + null); assertThatJson(metadata).isEqualTo("{\n" + " \"name\" : \"test\",\n" + diff --git a/server/src/test/java/com/linecorp/centraldogma/server/internal/api/AppIdentityRegistryServiceTest.java b/server/src/test/java/com/linecorp/centraldogma/server/internal/api/AppIdentityRegistryServiceTest.java index 9f582c209..c03c5d8e8 100644 --- a/server/src/test/java/com/linecorp/centraldogma/server/internal/api/AppIdentityRegistryServiceTest.java +++ b/server/src/test/java/com/linecorp/centraldogma/server/internal/api/AppIdentityRegistryServiceTest.java @@ -46,6 +46,7 @@ import com.linecorp.centraldogma.server.command.StandaloneCommandExecutor; import com.linecorp.centraldogma.server.internal.api.sysadmin.AppIdentityLevelRequest; import com.linecorp.centraldogma.server.internal.api.sysadmin.AppIdentityRegistryService; +import com.linecorp.centraldogma.server.internal.metadata.MetadataPropertiesValidator; import com.linecorp.centraldogma.server.metadata.AppIdentity; import com.linecorp.centraldogma.server.metadata.AppIdentityRegistry; import com.linecorp.centraldogma.server.metadata.AppIdentityType; @@ -89,7 +90,8 @@ static void setUp() throws JsonMappingException, JsonParseException { metadataService = new MetadataService(manager.projectManager(), manager.executor(), manager.internalProjectInitializer()); appIdentityRegistryService = new AppIdentityRegistryService(manager.executor(), metadataService, - true); + true, + new MetadataPropertiesValidator(null)); } @AfterEach @@ -149,7 +151,7 @@ void systemAdminToken() { void systemAdminAppIdentity() { final CertificateAppIdentity certificate = (CertificateAppIdentity) appIdentityRegistryService.createAppIdentity( - "certAdmin1", true, AppIdentityType.CERTIFICATE, null, "cert/123", + "certAdmin1", true, AppIdentityType.CERTIFICATE, null, "cert/123", null, systemAdminAuthor, systemAdmin).join().content(); assertThat(certificate.isActive()).isTrue(); assertThat(certificate.certificateId()).isEqualTo("cert/123"); @@ -157,11 +159,11 @@ void systemAdminAppIdentity() { () -> appIdentityRegistryService.createAppIdentity( "certAdmin2", true, AppIdentityType.CERTIFICATE, null, - "cert-456", guestAuthor, guest).join()) + "cert-456", null, guestAuthor, guest).join()) .isInstanceOf(IllegalArgumentException.class); final Token token = (Token) appIdentityRegistryService.createAppIdentity( - "tokenAdmin1", true, AppIdentityType.TOKEN, null, null, + "tokenAdmin1", true, AppIdentityType.TOKEN, null, null, null, systemAdminAuthor, systemAdmin).join().content(); assertThat(token.isActive()).isTrue(); assertThat(token.secret()).isNotNull(); @@ -254,11 +256,11 @@ void userToken() { void userCertificate() { final CertificateAppIdentity userCert1 = (CertificateAppIdentity) appIdentityRegistryService.createAppIdentity( - "certUser1", false, AppIdentityType.CERTIFICATE, null, "cert-user1", + "certUser1", false, AppIdentityType.CERTIFICATE, null, "cert-user1", null, systemAdminAuthor, systemAdmin).join().content(); final CertificateAppIdentity userCert2 = (CertificateAppIdentity) appIdentityRegistryService.createAppIdentity( - "certUser2", false, AppIdentityType.CERTIFICATE, null, "cert-user2", + "certUser2", false, AppIdentityType.CERTIFICATE, null, "cert-user2", null, guestAuthor, guest).join().content(); assertThat(userCert1.isActive()).isTrue(); assertThat(userCert2.isActive()).isTrue(); @@ -352,7 +354,7 @@ public void updateToken() { public void updateCertificate() { final CertificateAppIdentity certificate = (CertificateAppIdentity) appIdentityRegistryService.createAppIdentity( - "certUpdate", true, AppIdentityType.CERTIFICATE, null, "cert/update", + "certUpdate", true, AppIdentityType.CERTIFICATE, null, "cert/update", null, systemAdminAuthor, systemAdmin).join().content(); assertThat(certificate.isActive()).isTrue(); @@ -415,7 +417,7 @@ void updateTokenLevel() { void updateCertificateLevel() { final CertificateAppIdentity certificate = (CertificateAppIdentity) appIdentityRegistryService.createAppIdentity( - "certLevelUpdate", false, AppIdentityType.CERTIFICATE, null, "cert-level", + "certLevelUpdate", false, AppIdentityType.CERTIFICATE, null, "cert-level", null, systemAdminAuthor, systemAdmin).join().content(); assertThat(certificate.isActive()).isTrue(); assertThat(certificate.isSystemAdmin()).isFalse(); diff --git a/server/src/test/java/com/linecorp/centraldogma/server/internal/api/MetadataPropertiesTest.java b/server/src/test/java/com/linecorp/centraldogma/server/internal/api/MetadataPropertiesTest.java new file mode 100644 index 000000000..449052a7d --- /dev/null +++ b/server/src/test/java/com/linecorp/centraldogma/server/internal/api/MetadataPropertiesTest.java @@ -0,0 +1,361 @@ +/* + * Copyright 2026 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.centraldogma.server.internal.api; + +import static com.linecorp.centraldogma.internal.api.v1.HttpApiV1Constants.API_V1_PATH_PREFIX; +import static com.linecorp.centraldogma.testing.internal.auth.TestAuthMessageUtil.getSessionCookie; +import static com.linecorp.centraldogma.testing.internal.auth.TestAuthMessageUtil.login; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +import java.net.URI; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; + +import com.fasterxml.jackson.databind.JsonNode; + +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.common.AggregatedHttpResponse; +import com.linecorp.armeria.common.Cookie; +import com.linecorp.armeria.common.HttpData; +import com.linecorp.armeria.common.HttpHeaderNames; +import com.linecorp.armeria.common.HttpMethod; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.common.MediaType; +import com.linecorp.armeria.common.QueryParams; +import com.linecorp.armeria.common.RequestHeaders; +import com.linecorp.centraldogma.internal.Jackson; +import com.linecorp.centraldogma.server.CentralDogmaBuilder; +import com.linecorp.centraldogma.server.MetadataPropertiesConfig; +import com.linecorp.centraldogma.server.internal.admin.auth.SessionUtil; +import com.linecorp.centraldogma.testing.internal.auth.TestAuthMessageUtil; +import com.linecorp.centraldogma.testing.internal.auth.TestAuthProviderFactory; +import com.linecorp.centraldogma.testing.junit.CentralDogmaExtension; + +class MetadataPropertiesTest { + + private static final String PROJECT_SCHEMA = + '{' + + " \"type\": \"object\"," + + " \"properties\": {" + + " \"serviceId\": { \"type\": \"string\", \"pattern\": \"^[a-z][a-z0-9-]*$\" }" + + " }," + + " \"required\": [ \"serviceId\" ]" + + '}'; + + private static final String REPO_SCHEMA = + "{ \"type\": \"object\", \"properties\": { \"serviceId\": { \"type\": \"string\" } } }"; + + private static final String APP_IDENTITY_SCHEMA = + '{' + + " \"type\": \"object\"," + + " \"properties\": { \"serviceId\": { \"type\": \"string\" } }," + + " \"required\": [ \"serviceId\" ]" + + '}'; + + @RegisterExtension + static final CentralDogmaExtension dogma = new CentralDogmaExtension() { + @Override + protected void configure(CentralDogmaBuilder builder) { + builder.systemAdministrators(TestAuthMessageUtil.USERNAME); + builder.authProviderFactory(new TestAuthProviderFactory()); + try { + builder.metadataProperties(new MetadataPropertiesConfig( + Jackson.readTree(PROJECT_SCHEMA), + Jackson.readTree(REPO_SCHEMA), + Jackson.readTree(APP_IDENTITY_SCHEMA))); + } catch (Exception e) { + throw new AssertionError(e); + } + } + }; + + @RegisterExtension + static final CentralDogmaExtension plainDogma = new CentralDogmaExtension() { + @Override + protected void configure(CentralDogmaBuilder builder) { + builder.systemAdministrators(TestAuthMessageUtil.USERNAME); + builder.authProviderFactory(new TestAuthProviderFactory()); + } + }; + + private static WebClient client; + private static WebClient plainClient; + + @BeforeAll + static void setUp() throws Exception { + client = newSystemAdminClient(dogma); + plainClient = newSystemAdminClient(plainDogma); + } + + private static WebClient newSystemAdminClient(CentralDogmaExtension extension) throws Exception { + // Log in with a session cookie; an access token cannot be used here because creating one is + // itself subject to the appIdentity schema under test. + final URI uri = extension.httpClient().uri(); + final AggregatedHttpResponse response = login(extension.httpClient(), + TestAuthMessageUtil.USERNAME, + TestAuthMessageUtil.PASSWORD); + final Cookie sessionCookie = getSessionCookie(response); + final String csrfToken = Jackson.readTree(response.contentUtf8()).get("csrf_token").asText(); + return WebClient.builder(uri) + .addHeader(SessionUtil.X_CSRF_TOKEN, csrfToken) + .addHeader(HttpHeaderNames.COOKIE, sessionCookie.toCookieHeader()) + .build(); + } + + @Test + void exposesDeclaredSchemas() throws Exception { + final AggregatedHttpResponse res = client.get(API_V1_PATH_PREFIX + "metadataProperties") + .aggregate().join(); + assertThat(res.status()).isEqualTo(HttpStatus.OK); + final JsonNode schemas = Jackson.readTree(res.contentUtf8()); + assertThat(schemas.get("project")).isEqualTo(Jackson.readTree(PROJECT_SCHEMA)); + assertThat(schemas.get("repo")).isEqualTo(Jackson.readTree(REPO_SCHEMA)); + assertThat(schemas.get("appIdentity")).isEqualTo(Jackson.readTree(APP_IDENTITY_SCHEMA)); + + final AggregatedHttpResponse plainRes = plainClient.get(API_V1_PATH_PREFIX + "metadataProperties") + .aggregate().join(); + assertThat(plainRes.status()).isEqualTo(HttpStatus.OK); + assertThat(Jackson.readTree(plainRes.contentUtf8())).isEqualTo(Jackson.readTree("{}")); + } + + @Test + void createsProjectWithDeclaredProperties() throws Exception { + final AggregatedHttpResponse res = postJson( + client, "projects", + "{\"name\":\"prj1\",\"properties\":{\"serviceId\":\"foo-service\",\"undeclared\":\"x\"}}"); + assertThat(res.status()).isEqualTo(HttpStatus.CREATED); + + // Undeclared properties are dropped rather than stored. + final JsonNode metadata = projectMetadata(client, "prj1"); + assertThat(metadata.get("properties")).isEqualTo(Jackson.readTree("{\"serviceId\":\"foo-service\"}")); + } + + @Test + void rejectsProjectPropertiesViolatingSchema() { + AggregatedHttpResponse res = postJson(client, "projects", "{\"name\":\"prj-invalid\"}"); + assertThat(res.status()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(res.contentUtf8()).contains("serviceId"); + + res = postJson(client, "projects", "{\"name\":\"prj-invalid\",\"properties\":{\"serviceId\":42}}"); + assertThat(res.status()).isEqualTo(HttpStatus.BAD_REQUEST); + + res = postJson(client, "projects", + "{\"name\":\"prj-invalid\",\"properties\":{\"serviceId\":\"FOO\"}}"); + assertThat(res.status()).isEqualTo(HttpStatus.BAD_REQUEST); + + res = postJson(client, "projects", "{\"name\":\"prj-invalid\",\"properties\":[\"a\"]}"); + assertThat(res.status()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(res.contentUtf8()).contains("must be a JSON object"); + + // An object with only undeclared keys is equivalent to an empty one. + res = postJson(client, "projects", "{\"name\":\"prj-invalid\",\"properties\":{\"foo\":\"x\"}}"); + assertThat(res.status()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(res.contentUtf8()).contains("serviceId"); + + // An explicit JSON null is equivalent to an absent field. + res = postJson(client, "projects", "{\"name\":\"prj-invalid\",\"properties\":null}"); + assertThat(res.status()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(res.contentUtf8()).contains("serviceId"); + } + + @Test + void rejectsRepositoryPropertiesViolatingSchema() { + final AggregatedHttpResponse created = postJson( + client, "projects", "{\"name\":\"prj4\",\"properties\":{\"serviceId\":\"repo-neg\"}}"); + assertThat(created.status()).isEqualTo(HttpStatus.CREATED); + + final AggregatedHttpResponse res = postJson( + client, "projects/prj4/repos", "{\"name\":\"badrepo\",\"properties\":{\"serviceId\":123}}"); + assertThat(res.status()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(res.contentUtf8()).contains("serviceId"); + } + + @Test + void mutationsPreserveProperties() throws Exception { + AggregatedHttpResponse res = postJson( + client, "projects", "{\"name\":\"prj3\",\"properties\":{\"serviceId\":\"mut-service\"}}"); + assertThat(res.status()).isEqualTo(HttpStatus.CREATED); + res = postJson(client, "projects/prj3/repos", + "{\"name\":\"repo1\",\"properties\":{\"serviceId\":\"mut-repo\"}}"); + assertThat(res.status()).isEqualTo(HttpStatus.CREATED); + + // Removing and unremoving a project rewrites the whole project metadata. + assertThat(client.delete(API_V1_PATH_PREFIX + "projects/prj3").aggregate().join().status()) + .isEqualTo(HttpStatus.NO_CONTENT); + assertThat(unremove(client, "projects/prj3").status()).isEqualTo(HttpStatus.OK); + + // Removing and restoring a repository rewrites its repository metadata. + assertThat(client.delete(API_V1_PATH_PREFIX + "projects/prj3/repos/repo1") + .aggregate().join().status()).isEqualTo(HttpStatus.NO_CONTENT); + assertThat(unremove(client, "projects/prj3/repos/repo1").status()).isEqualTo(HttpStatus.OK); + + final JsonNode metadata = projectMetadata(client, "prj3"); + assertThat(metadata.get("properties")).isEqualTo(Jackson.readTree("{\"serviceId\":\"mut-service\"}")); + assertThat(metadata.get("repos").get("repo1").get("properties")) + .isEqualTo(Jackson.readTree("{\"serviceId\":\"mut-repo\"}")); + + // Deactivating and activating an app identity rebuilds its registry entry. + res = client.post(API_V1_PATH_PREFIX + "appIdentities", + QueryParams.of("appId", "app-mut", "type", "TOKEN", + "properties", "{\"serviceId\":\"mut-app\"}"), + HttpData.empty()).aggregate().join(); + assertThat(res.status()).isEqualTo(HttpStatus.CREATED); + assertThat(patchJson(client, "appIdentities/app-mut", "{\"status\":\"inactive\"}").status()) + .isEqualTo(HttpStatus.OK); + assertThat(patchJson(client, "appIdentities/app-mut", "{\"status\":\"active\"}").status()) + .isEqualTo(HttpStatus.OK); + await().untilAsserted(() -> { + final AggregatedHttpResponse listRes = client.get(API_V1_PATH_PREFIX + "appIdentities") + .aggregate().join(); + JsonNode found = null; + for (JsonNode appIdentity : Jackson.readTree(listRes.contentUtf8())) { + if ("app-mut".equals(appIdentity.get("appId").asText())) { + found = appIdentity; + } + } + assertThat(found).isNotNull(); + assertThat(found.get("properties")).isEqualTo(Jackson.readTree("{\"serviceId\":\"mut-app\"}")); + }); + } + + @Test + void createsRepositoryWithPropertiesAndPreservesProjectProperties() throws Exception { + AggregatedHttpResponse res = postJson( + client, "projects", "{\"name\":\"prj2\",\"properties\":{\"serviceId\":\"bar-service\"}}"); + assertThat(res.status()).isEqualTo(HttpStatus.CREATED); + + res = postJson(client, "projects/prj2/repos", + "{\"name\":\"repo1\",\"properties\":{\"serviceId\":\"baz-service\",\"u\":\"v\"}}"); + assertThat(res.status()).isEqualTo(HttpStatus.CREATED); + + // The repository schema has no required property, so a repository can be created without one. + res = postJson(client, "projects/prj2/repos", "{\"name\":\"repo2\"}"); + assertThat(res.status()).isEqualTo(HttpStatus.CREATED); + + final JsonNode metadata = projectMetadata(client, "prj2"); + // Adding repositories rewrites the project metadata; the project properties must survive it. + assertThat(metadata.get("properties")).isEqualTo(Jackson.readTree("{\"serviceId\":\"bar-service\"}")); + assertThat(metadata.get("repos").get("repo1").get("properties")) + .isEqualTo(Jackson.readTree("{\"serviceId\":\"baz-service\"}")); + assertThat(metadata.get("repos").get("repo2").get("properties")).isNull(); + } + + @Test + void createsAppIdentityWithProperties() throws Exception { + final AggregatedHttpResponse res = client.post( + API_V1_PATH_PREFIX + "appIdentities", + QueryParams.of("appId", "app-props", "type", "TOKEN", + "properties", "{\"serviceId\":\"qux-service\",\"undeclared\":\"x\"}"), + HttpData.empty()).aggregate().join(); + assertThat(res.status()).isEqualTo(HttpStatus.CREATED); + assertThat(Jackson.readTree(res.contentUtf8()).get("properties")) + .isEqualTo(Jackson.readTree("{\"serviceId\":\"qux-service\"}")); + + // The properties must survive the round trip through /tokens.json. + await().untilAsserted(() -> { + final AggregatedHttpResponse listRes = client.get(API_V1_PATH_PREFIX + "appIdentities") + .aggregate().join(); + JsonNode found = null; + for (JsonNode appIdentity : Jackson.readTree(listRes.contentUtf8())) { + if ("app-props".equals(appIdentity.get("appId").asText())) { + found = appIdentity; + } + } + assertThat(found).isNotNull(); + assertThat(found.get("properties")) + .isEqualTo(Jackson.readTree("{\"serviceId\":\"qux-service\"}")); + }); + + AggregatedHttpResponse badRes = client.post( + API_V1_PATH_PREFIX + "appIdentities", + QueryParams.of("appId", "app-no-props", "type", "TOKEN"), + HttpData.empty()).aggregate().join(); + assertThat(badRes.status()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(badRes.contentUtf8()).contains("serviceId"); + + badRes = client.post( + API_V1_PATH_PREFIX + "appIdentities", + QueryParams.of("appId", "app-bad-props", "type", "TOKEN", "properties", "not-json"), + HttpData.empty()).aggregate().join(); + assertThat(badRes.status()).isEqualTo(HttpStatus.BAD_REQUEST); + + badRes = client.post( + API_V1_PATH_PREFIX + "appIdentities", + QueryParams.of("appId", "app-non-object", "type", "TOKEN", "properties", "42"), + HttpData.empty()).aggregate().join(); + assertThat(badRes.status()).isEqualTo(HttpStatus.BAD_REQUEST); + assertThat(badRes.contentUtf8()).contains("must be a JSON object"); + } + + @Test + void ignoresPropertiesWhenNothingDeclared() throws Exception { + AggregatedHttpResponse res = postJson( + plainClient, "projects", + "{\"name\":\"prj-plain\",\"properties\":{\"serviceId\":\"foo-service\"}}"); + assertThat(res.status()).isEqualTo(HttpStatus.CREATED); + + res = postJson(plainClient, "projects/prj-plain/repos", + "{\"name\":\"repo1\",\"properties\":{\"serviceId\":\"foo-service\"}}"); + assertThat(res.status()).isEqualTo(HttpStatus.CREATED); + + final JsonNode metadata = projectMetadata(plainClient, "prj-plain"); + assertThat(metadata.get("properties")).isNull(); + assertThat(metadata.get("repos").get("repo1").get("properties")).isNull(); + + final AggregatedHttpResponse appRes = plainClient.post( + API_V1_PATH_PREFIX + "appIdentities", + QueryParams.of("appId", "app-plain", "type", "TOKEN", + "properties", "{\"serviceId\":\"foo-service\"}"), + HttpData.empty()).aggregate().join(); + assertThat(appRes.status()).isEqualTo(HttpStatus.CREATED); + assertThat(Jackson.readTree(appRes.contentUtf8()).get("properties")).isNull(); + + // An explicit JSON null is equivalent to an absent field. + res = postJson(plainClient, "projects", "{\"name\":\"prj-plain2\",\"properties\":null}"); + assertThat(res.status()).isEqualTo(HttpStatus.CREATED); + } + + private static AggregatedHttpResponse postJson(WebClient client, String path, String body) { + final RequestHeaders headers = RequestHeaders.of(HttpMethod.POST, API_V1_PATH_PREFIX + path, + HttpHeaderNames.CONTENT_TYPE, MediaType.JSON); + return client.execute(headers, body).aggregate().join(); + } + + private static AggregatedHttpResponse patchJson(WebClient client, String path, String body) { + final RequestHeaders headers = RequestHeaders.of(HttpMethod.PATCH, API_V1_PATH_PREFIX + path, + HttpHeaderNames.CONTENT_TYPE, MediaType.JSON); + return client.execute(headers, body).aggregate().join(); + } + + private static AggregatedHttpResponse unremove(WebClient client, String path) { + final RequestHeaders headers = RequestHeaders.of(HttpMethod.PATCH, API_V1_PATH_PREFIX + path, + HttpHeaderNames.CONTENT_TYPE, + "application/json-patch+json"); + return client.execute(headers, "[{\"op\":\"replace\",\"path\":\"/status\",\"value\":\"active\"}]") + .aggregate().join(); + } + + private static JsonNode projectMetadata(WebClient client, String projectName) throws Exception { + final AggregatedHttpResponse res = client.get(API_V1_PATH_PREFIX + "projects/" + projectName) + .aggregate().join(); + assertThat(res.status()).isEqualTo(HttpStatus.OK); + return Jackson.readTree(res.contentUtf8()); + } +} diff --git a/server/src/test/java/com/linecorp/centraldogma/server/internal/metadata/MetadataPropertiesValidatorTest.java b/server/src/test/java/com/linecorp/centraldogma/server/internal/metadata/MetadataPropertiesValidatorTest.java new file mode 100644 index 000000000..7d39a2f88 --- /dev/null +++ b/server/src/test/java/com/linecorp/centraldogma/server/internal/metadata/MetadataPropertiesValidatorTest.java @@ -0,0 +1,154 @@ +/* + * Copyright 2026 LINE Corporation + * + * LINE Corporation licenses this file to you under the Apache License, + * version 2.0 (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package com.linecorp.centraldogma.server.internal.metadata; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.JsonNode; + +import com.linecorp.centraldogma.internal.Jackson; +import com.linecorp.centraldogma.server.MetadataPropertiesConfig; +import com.linecorp.centraldogma.server.internal.metadata.MetadataPropertiesValidator.ResourceType; + +class MetadataPropertiesValidatorTest { + + private static final String SCHEMA = + '{' + + " \"type\": \"object\"," + + " \"properties\": {" + + " \"serviceId\": { \"type\": \"string\", \"pattern\": \"^[a-z][a-z0-9-]*$\" }," + + " \"replicas\": { \"type\": \"integer\" }" + + " }," + + " \"required\": [ \"serviceId\" ]" + + '}'; + + @Test + void ignoresEverythingWithoutConfig() throws Exception { + final MetadataPropertiesValidator validator = new MetadataPropertiesValidator(null); + assertThat(validator.validate(ResourceType.PROJECT, null)).isNull(); + assertThat(validator.validate(ResourceType.PROJECT, Jackson.readTree("{\"foo\":\"bar\"}"))).isNull(); + } + + @Test + void ignoresResourceTypeWithoutSchema() throws Exception { + final MetadataPropertiesValidator validator = newValidator(); + assertThat(validator.validate(ResourceType.REPO, Jackson.readTree("{\"foo\":\"bar\"}"))).isNull(); + // Even a non-object value is ignored when no schema is declared for the resource type. + assertThat(validator.validate(ResourceType.REPO, Jackson.readTree("[]"))).isNull(); + } + + @Test + void treatsExplicitNullAsAbsent() throws Exception { + assertThat(new MetadataPropertiesValidator(null) + .validate(ResourceType.PROJECT, Jackson.readTree("null"))).isNull(); + assertThatThrownBy(() -> newValidator().validate(ResourceType.PROJECT, Jackson.readTree("null"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("serviceId"); + } + + @Test + void validatesObjectAsIsWhenSchemaDeclaresNoTopLevelProperties() throws Exception { + final MetadataPropertiesValidator validator = new MetadataPropertiesValidator( + new MetadataPropertiesConfig( + Jackson.readTree("{\"type\":\"object\",\"required\":[\"serviceId\"]}"), null, null)); + final JsonNode validated = validator.validate( + ResourceType.PROJECT, Jackson.readTree("{\"serviceId\":\"foo\",\"extra\":1}")); + assertThat(validated).isEqualTo(Jackson.readTree("{\"serviceId\":\"foo\",\"extra\":1}")); + assertThatThrownBy(() -> validator.validate(ResourceType.PROJECT, Jackson.readTree("{}"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("serviceId"); + } + + @Test + void dropsUndeclaredProperties() throws Exception { + final MetadataPropertiesValidator validator = newValidator(); + final JsonNode validated = validator.validate( + ResourceType.PROJECT, Jackson.readTree("{\"serviceId\":\"foo\",\"undeclared\":\"x\"}")); + assertThat(validated).isEqualTo(Jackson.readTree("{\"serviceId\":\"foo\"}")); + } + + @Test + void keepsDeclaredTypedValues() throws Exception { + final MetadataPropertiesValidator validator = newValidator(); + final JsonNode validated = validator.validate( + ResourceType.PROJECT, Jackson.readTree("{\"serviceId\":\"foo\",\"replicas\":3}")); + assertThat(validated).isEqualTo(Jackson.readTree("{\"serviceId\":\"foo\",\"replicas\":3}")); + } + + @Test + void rejectsMissingRequiredProperty() { + final MetadataPropertiesValidator validator = newValidator(); + assertThatThrownBy(() -> validator.validate(ResourceType.PROJECT, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("serviceId"); + assertThatThrownBy(() -> validator.validate(ResourceType.PROJECT, Jackson.readTree("{}"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("serviceId"); + // A request whose properties are all undeclared is equivalent to an empty one. + assertThatThrownBy( + () -> validator.validate(ResourceType.PROJECT, Jackson.readTree("{\"undeclared\":\"x\"}"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("serviceId"); + } + + @Test + void rejectsTypeMismatch() { + final MetadataPropertiesValidator validator = newValidator(); + assertThatThrownBy(() -> validator.validate( + ResourceType.PROJECT, Jackson.readTree("{\"serviceId\":\"foo\",\"replicas\":\"many\"}"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("replicas"); + } + + @Test + void rejectsPatternViolation() { + final MetadataPropertiesValidator validator = newValidator(); + assertThatThrownBy( + () -> validator.validate(ResourceType.PROJECT, Jackson.readTree("{\"serviceId\":\"FOO\"}"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("serviceId"); + } + + @Test + void rejectsNonObjectProperties() { + final MetadataPropertiesValidator validator = newValidator(); + assertThatThrownBy(() -> validator.validate(ResourceType.PROJECT, Jackson.readTree("[]"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must be a JSON object"); + } + + @Test + void failsFastOnInvalidSchema() { + assertThatThrownBy(() -> new MetadataPropertiesValidator(new MetadataPropertiesConfig( + Jackson.readTree("{\"type\":\"object\",\"properties\":{\"a\":{\"pattern\":\"[\"}}}"), + null, null))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("metadataProperties.project"); + } + + private static MetadataPropertiesValidator newValidator() { + try { + return new MetadataPropertiesValidator( + new MetadataPropertiesConfig(Jackson.readTree(SCHEMA), null, null)); + } catch (Exception e) { + throw new AssertionError(e); + } + } +} diff --git a/server/src/test/java/com/linecorp/centraldogma/server/metadata/AppIdentityDeserializerTest.java b/server/src/test/java/com/linecorp/centraldogma/server/metadata/AppIdentityDeserializerTest.java index b62a9e302..440ab846c 100644 --- a/server/src/test/java/com/linecorp/centraldogma/server/metadata/AppIdentityDeserializerTest.java +++ b/server/src/test/java/com/linecorp/centraldogma/server/metadata/AppIdentityDeserializerTest.java @@ -24,6 +24,24 @@ class AppIdentityDeserializerTest { + @Test + void deserializeLegacyTokenWithProperties() throws Exception { + final String legacyTokenJson = '{' + + " \"appId\": \"legacy-app\"," + + " \"secret\": \"legacy-secret\"," + + " \"creation\": {" + + " \"user\": \"admin@localhost.com\"," + + " \"timestamp\": \"2025-01-01T00:00:00Z\"" + + " }," + + " \"properties\": { \"serviceId\": \"foo\" }" + + '}'; + + final AppIdentity appIdentity = Jackson.readValue(legacyTokenJson, AppIdentity.class); + + assertThat(appIdentity).isInstanceOf(Token.class); + assertThat(appIdentity.properties()).isEqualTo(Jackson.readTree("{\"serviceId\":\"foo\"}")); + } + @Test void deserializeLegacyTokenWithoutType() throws Exception { final String legacyTokenJson = '{' + diff --git a/server/src/test/java/com/linecorp/centraldogma/server/metadata/AppIdentityRegistryTest.java b/server/src/test/java/com/linecorp/centraldogma/server/metadata/AppIdentityRegistryTest.java index 96c20edc5..571589437 100644 --- a/server/src/test/java/com/linecorp/centraldogma/server/metadata/AppIdentityRegistryTest.java +++ b/server/src/test/java/com/linecorp/centraldogma/server/metadata/AppIdentityRegistryTest.java @@ -32,7 +32,7 @@ class AppIdentityRegistryTest { @Test void nullCertificateIds() { final UserAndTimestamp creation = UserAndTimestamp.of(Author.SYSTEM); - final Token token = new Token("app-id-1", "appToken-secret-1", false, null, creation, null, null); + final Token token = new Token("app-id-1", "appToken-secret-1", false, null, creation, null, null, null); final ImmutableMap appIds = ImmutableMap.of("app-id-1", token); final ImmutableMap secrets = ImmutableMap.of("appToken-secret-1", "app-id-1"); @@ -47,9 +47,9 @@ void nullCertificateIds() { @Test void withCertificateIds() { final UserAndTimestamp creation = UserAndTimestamp.of(Author.SYSTEM); - final Token token = new Token("app-id-1", "appToken-secret-1", false, null, creation, null, null); + final Token token = new Token("app-id-1", "appToken-secret-1", false, null, creation, null, null, null); final CertificateAppIdentity certAppIdentity = - new CertificateAppIdentity("app-id-2", "cert-id", false, false, creation); + new CertificateAppIdentity("app-id-2", "cert-id", false, false, creation, null); final ImmutableMap appIds = ImmutableMap.of("app-id-1", token, "app-id-2", certAppIdentity); final ImmutableMap secrets = ImmutableMap.of("appToken-secret-1", "app-id-1"); @@ -139,9 +139,9 @@ void deserializationWithCertificateIds() throws Exception { @Test void serializationIncludesCertificateIds() throws Exception { final UserAndTimestamp creation = UserAndTimestamp.of(Author.SYSTEM); - final Token token = new Token("app-id-1", "appToken-secret-1", false, null, creation, null, null); + final Token token = new Token("app-id-1", "appToken-secret-1", false, null, creation, null, null, null); final CertificateAppIdentity certAppIdentity = - new CertificateAppIdentity("app-id-2", "cert-id", false, false, creation); + new CertificateAppIdentity("app-id-2", "cert-id", false, false, creation, null); final ImmutableMap appIds = ImmutableMap.of("app-id-1", token, "app-id-2", certAppIdentity); final ImmutableMap secrets = ImmutableMap.of("appToken-secret-1", "app-id-1"); @@ -159,9 +159,9 @@ void serializationIncludesCertificateIds() throws Exception { @Test void roundTripSerialization() throws Exception { final UserAndTimestamp creation = UserAndTimestamp.of(Author.SYSTEM); - final Token token = new Token("app-id-1", "appToken-secret-1", false, null, creation, null, null); + final Token token = new Token("app-id-1", "appToken-secret-1", false, null, creation, null, null, null); final CertificateAppIdentity certAppIdentity = - new CertificateAppIdentity("app-id-2", "cert-id", false, false, creation); + new CertificateAppIdentity("app-id-2", "cert-id", false, false, creation, null); final ImmutableMap appIds = ImmutableMap.of("app-id-1", token, "app-id-2", certAppIdentity); final ImmutableMap secrets = ImmutableMap.of("appToken-secret-1", "app-id-1"); @@ -184,7 +184,7 @@ void roundTripSerialization() throws Exception { @Test void roundTripSerializationWithoutCertificateIds() throws Exception { final UserAndTimestamp creation = UserAndTimestamp.of(Author.SYSTEM); - final Token token = new Token("app-id-1", "appToken-secret-1", false, null, creation, null, null); + final Token token = new Token("app-id-1", "appToken-secret-1", false, null, creation, null, null, null); final ImmutableMap appIds = ImmutableMap.of("app-id-1", token); final ImmutableMap secrets = ImmutableMap.of("appToken-secret-1", "app-id-1"); final AppIdentityRegistry originalAppIdentityRegistry = new AppIdentityRegistry(appIds, secrets, null); diff --git a/server/src/test/java/com/linecorp/centraldogma/server/metadata/CertificateAppIdentityTest.java b/server/src/test/java/com/linecorp/centraldogma/server/metadata/CertificateAppIdentityTest.java index ba6857ebd..c48ce133c 100644 --- a/server/src/test/java/com/linecorp/centraldogma/server/metadata/CertificateAppIdentityTest.java +++ b/server/src/test/java/com/linecorp/centraldogma/server/metadata/CertificateAppIdentityTest.java @@ -39,6 +39,7 @@ void serialize() throws Exception { false, creation, null, + null, null ); @@ -102,6 +103,7 @@ void serializeAndDeserialize() throws Exception { true, creation, deactivation, + null, null ); diff --git a/server/src/test/java/com/linecorp/centraldogma/server/metadata/MetadataServiceTest.java b/server/src/test/java/com/linecorp/centraldogma/server/metadata/MetadataServiceTest.java index 8a897da20..0ead20f67 100644 --- a/server/src/test/java/com/linecorp/centraldogma/server/metadata/MetadataServiceTest.java +++ b/server/src/test/java/com/linecorp/centraldogma/server/metadata/MetadataServiceTest.java @@ -78,12 +78,14 @@ protected boolean runForEachTest() { private static final String cert2 = "cert-2"; private static final String certificateId1 = "certificate/id/1"; private static final String certificateId2 = "certificate/id/2"; - private static final Token appToken1 = new Token(app1, "secret", false, true, UserAndTimestamp.of(author)); - private static final Token appToken2 = new Token(app2, "secret", false, true, UserAndTimestamp.of(author)); + private static final Token appToken1 = + new Token(app1, "secret", false, true, UserAndTimestamp.of(author), null); + private static final Token appToken2 = + new Token(app2, "secret", false, true, UserAndTimestamp.of(author), null); private static final CertificateAppIdentity certificate1 = - new CertificateAppIdentity(cert1, certificateId1, false, true, UserAndTimestamp.of(author)); + new CertificateAppIdentity(cert1, certificateId1, false, true, UserAndTimestamp.of(author), null); private static final CertificateAppIdentity certificate2 = - new CertificateAppIdentity(cert2, certificateId2, false, true, UserAndTimestamp.of(author)); + new CertificateAppIdentity(cert2, certificateId2, false, true, UserAndTimestamp.of(author), null); @Test void project() { @@ -227,7 +229,7 @@ void userRepositoryRole() { final MetadataService mds = newMetadataService(manager); final RepositoryMetadata repositoryMetadata = - RepositoryMetadata.of(repo1, Roles.EMPTY, UserAndTimestamp.of(author)); + RepositoryMetadata.of(repo1, Roles.EMPTY, UserAndTimestamp.of(author), null); mds.addRepo(author, project1, repo1, repositoryMetadata).join(); await().until(() -> getRepo1(mds) != null); @@ -475,8 +477,8 @@ void destroyCertificate() { final MetadataService mds = newMetadataService(manager); mds.addRepo(author, project1, repo1, ProjectRoles.of(null, null)).join(); - mds.createCertificate(author, cert1, certificateId1, false).join(); - mds.createCertificate(author, cert2, certificateId2, false).join(); + mds.createCertificate(author, cert1, certificateId1, false, null).join(); + mds.createCertificate(author, cert2, certificateId2, false, null).join(); mds.addAppIdentity(author, project1, cert1, ProjectRole.MEMBER).join(); mds.addAppIdentity(author, project1, cert2, ProjectRole.MEMBER).join(); @@ -507,8 +509,8 @@ void removeCertificate() { final MetadataService mds = newMetadataService(manager); mds.addRepo(author, project1, repo1, ProjectRoles.of(null, null)).join(); - mds.createCertificate(author, cert1, certificateId1, false).join(); - mds.createCertificate(author, cert2, certificateId2, false).join(); + mds.createCertificate(author, cert1, certificateId1, false, null).join(); + mds.createCertificate(author, cert2, certificateId2, false, null).join(); mds.addAppIdentity(author, project1, cert1, ProjectRole.MEMBER).join(); mds.addAppIdentity(author, project1, cert2, ProjectRole.MEMBER).join(); @@ -558,7 +560,7 @@ void tokenActivationAndDeactivation() { void certificateActivationAndDeactivation() { final MetadataService mds = newMetadataService(manager); - mds.createCertificate(author, cert1, certificateId1, false).join(); + mds.createCertificate(author, cert1, certificateId1, false, null).join(); await().untilAsserted(() -> assertThat(mds.getAppIdentityRegistry().get(cert1)).isNotNull()); assertThat(mds.getAppIdentityRegistry().get(cert1).creation().user()).isEqualTo(owner.id()); assertThat(mds.getAppIdentityRegistry().get(cert1).type()).isEqualTo(AppIdentityType.CERTIFICATE); @@ -699,7 +701,7 @@ void effectiveRepositoryRole_appIdentityWithoutGuestAccess() { // but has an explicit repository role, it should still be accessible. final MetadataService mds = newMetadataService(manager); - final Token noGuestToken = new Token(app1, "secret", false, false, UserAndTimestamp.of(author)); + final Token noGuestToken = new Token(app1, "secret", false, false, UserAndTimestamp.of(author), null); mds.addRepo(author, project1, repo1, ProjectRoles.of(RepositoryRole.WRITE, RepositoryRole.READ)).join(); @@ -715,7 +717,7 @@ void effectiveRepositoryRole_appIdentityWithoutGuestAccess() { // Without an explicit repository role, allowGuestAccess=false should deny access. assertThat(mds.findRepositoryRole(project1, repo1, - new Token(app2, "secret2", false, false, UserAndTimestamp.of(author))).join()) + new Token(app2, "secret2", false, false, UserAndTimestamp.of(author), null)).join()) .isNull(); } @@ -815,8 +817,8 @@ private static void createTokenAndVerifyDuplicateFails(MetadataService mds, Stri private static void createCertificateAndVerifyDuplicateFails(MetadataService mds, String appId, String certificateId) { - mds.createCertificate(author, appId, certificateId, false).join(); - assertThatThrownBy(() -> mds.createCertificate(author, appId, certificateId, false).join()) + mds.createCertificate(author, appId, certificateId, false, null).join(); + assertThatThrownBy(() -> mds.createCertificate(author, appId, certificateId, false, null).join()) .hasCauseInstanceOf(ChangeConflictException.class); } diff --git a/server/src/test/java/com/linecorp/centraldogma/server/metadata/ProjectMetadataTest.java b/server/src/test/java/com/linecorp/centraldogma/server/metadata/ProjectMetadataTest.java index e3500660e..5f822c066 100644 --- a/server/src/test/java/com/linecorp/centraldogma/server/metadata/ProjectMetadataTest.java +++ b/server/src/test/java/com/linecorp/centraldogma/server/metadata/ProjectMetadataTest.java @@ -70,7 +70,7 @@ void serialize() throws Exception { new Roles(ProjectRoles.of(null, null), ImmutableMap.of(), null, ImmutableMap.of()), creation, - null, RepositoryStatus.ACTIVE)), + null, RepositoryStatus.ACTIVE, null)), ImmutableMap.of("user1@example.com", new Member("user1@example.com", ProjectRole.MEMBER, creation) ), @@ -79,6 +79,7 @@ void serialize() throws Exception { new AppIdentityRegistration("app-id-1", ProjectRole.MEMBER, creation) ), new UserAndTimestamp(User.SYSTEM.id()), + null, null); final String json = Jackson.writeValueAsString(metadata); diff --git a/server/src/test/java/com/linecorp/centraldogma/server/metadata/TokenTest.java b/server/src/test/java/com/linecorp/centraldogma/server/metadata/TokenTest.java index ec105cdc6..61dfa52a5 100644 --- a/server/src/test/java/com/linecorp/centraldogma/server/metadata/TokenTest.java +++ b/server/src/test/java/com/linecorp/centraldogma/server/metadata/TokenTest.java @@ -44,6 +44,7 @@ import com.linecorp.centraldogma.internal.Jackson; import com.linecorp.centraldogma.server.internal.api.sysadmin.AppIdentityLevelRequest; import com.linecorp.centraldogma.server.internal.api.sysadmin.AppIdentityRegistryService; +import com.linecorp.centraldogma.server.internal.metadata.MetadataPropertiesValidator; import com.linecorp.centraldogma.server.storage.project.InternalProjectInitializer; import com.linecorp.centraldogma.server.storage.project.Project; import com.linecorp.centraldogma.server.storage.repository.Repository; @@ -70,7 +71,8 @@ static void setUp() throws JsonProcessingException { metadataService = new MetadataService(manager.projectManager(), manager.executor(), manager.internalProjectInitializer()); appIdentityRegistryService = new AppIdentityRegistryService(manager.executor(), metadataService, - false); + false, + new MetadataPropertiesValidator(null)); // Put the legacy token. final Repository dogmaRepository = diff --git a/site/src/sphinx/setup-configuration.rst b/site/src/sphinx/setup-configuration.rst index 204e19f2e..0f0478b5e 100644 --- a/site/src/sphinx/setup-configuration.rst +++ b/site/src/sphinx/setup-configuration.rst @@ -62,7 +62,8 @@ defaults: "protocol": null, "path": null }, - "zone": null + "zone": null, + "metadataProperties": null } Core properties @@ -263,6 +264,42 @@ Core properties - the current zone name must be included in the list of zone names. +- ``metadataProperties`` + + - the additional metadata properties of projects, repositories and app identities. Each field is a + `JSON Schema `_ that the ``properties`` of the corresponding resource must + conform to at creation time. Properties that are not declared in the schema's top-level + ``properties`` keyword are silently dropped rather than rejected, so that a new property can be + declared with a rolling restart. If the schema declares its shape in another way (e.g. ``$ref`` or + ``allOf``), nothing is dropped and the whole object is validated as is. + If not specified, the creation APIs behave as before. + + - all replicas must be running a binary that understands ``metadataProperties`` before this section + is enabled. Also note that declaring a ``required`` property for ``appIdentity`` effectively + disables the deprecated ``POST /api/v1/tokens`` endpoint, which cannot carry properties. + + - ``project`` / ``repo`` / ``appIdentity`` (object) + + - the JSON Schema for the properties of a project, a repository and an app identity respectively. + For example: + + .. code-block:: json + + { + "metadataProperties": { + "project": { + "type": "object", + "properties": { + "serviceId": { "type": "string" } + }, + "required": [ "serviceId" ] + } + } + } + + - the declared schemas are exposed via ``GET /api/v1/metadataProperties`` so that clients such as + the web UI can render input forms for the declared properties. + .. _replication: Configuring replication diff --git a/xds/src/main/java/com/linecorp/centraldogma/xds/group/v1/XdsGroupService.java b/xds/src/main/java/com/linecorp/centraldogma/xds/group/v1/XdsGroupService.java index 8d2953b62..040f3e778 100644 --- a/xds/src/main/java/com/linecorp/centraldogma/xds/group/v1/XdsGroupService.java +++ b/xds/src/main/java/com/linecorp/centraldogma/xds/group/v1/XdsGroupService.java @@ -82,7 +82,7 @@ public CompletableFuture createGroup(@Param("group_id") String gro errorResponse(HttpStatus.UNAUTHORIZED, "Authentication required")); } return createRepository(commandExecutor, mds, getAuthor(createUser), INTERNAL_PROJECT_XDS, groupId, - false, null) + false, null, null) .handle((unused, cause) -> { if (cause != null) { final Throwable peeled = Exceptions.peel(cause); diff --git a/xds/src/test/java/com/linecorp/centraldogma/xds/internal/CreatingInternalGroupPlugin.java b/xds/src/test/java/com/linecorp/centraldogma/xds/internal/CreatingInternalGroupPlugin.java index 0176fa334..b8dc78362 100644 --- a/xds/src/test/java/com/linecorp/centraldogma/xds/internal/CreatingInternalGroupPlugin.java +++ b/xds/src/test/java/com/linecorp/centraldogma/xds/internal/CreatingInternalGroupPlugin.java @@ -41,7 +41,7 @@ public void init(PluginInitContext pluginInitContext) { pluginInitContext.commandExecutor(), pluginInitContext.internalProjectInitializer()); RepositoryServiceUtil.createRepository(pluginInitContext.commandExecutor(), mds, Author.SYSTEM, - INTERNAL_PROJECT_XDS, "my-group", false, null) + INTERNAL_PROJECT_XDS, "my-group", false, null, null) .join(); }