diff --git a/migrations/20260605150000-add-ownership-to-published-data.js b/migrations/20260605150000-add-ownership-to-published-data.js new file mode 100644 index 000000000..6cdcbb2de --- /dev/null +++ b/migrations/20260605150000-add-ownership-to-published-data.js @@ -0,0 +1,50 @@ +module.exports = { + async up(db, client) { + // Add ownerGroup and ensure status for all PublishedData records + // Legacy records without ownerGroup should be accessible to everyone + // We set ownerGroup to "public" for backward compatibility + + await db + .collection("PublishedData") + .find({ + $or: [ + { ownerGroup: { $exists: false } }, + { ownerGroup: null }, + ], + }) + .forEach(async (publishedData) => { + const pid = publishedData._id; + + // Set default ownerGroup to "public" for backward compatibility + // Set default status to "public" if not set or is null + const update = { + $set: { + ownerGroup: "public", + }, + }; + + // If status is not set or is null, set it to PUBLIC + if (!publishedData.status || publishedData.status === null) { + update.$set.status = "public"; + } + + console.log(`Updating PublishedData (Id: ${pid}) with ownerGroup and status`); + await db.collection("PublishedData").updateOne( + { _id: pid }, + update, + ); + }); + }, + async down(db, client) { + // Remove ownerGroup from records that were added by this migration + // We can't easily revert this, but we'll unset ownerGroup for records that had it set to "public" + await db + .collection("PublishedData") + .updateMany( + { ownerGroup: "public" }, + { $unset: { ownerGroup: true } }, + ); + + console.log("Reverted ownerGroup for public records"); + }, +}; diff --git a/package-lock.json b/package-lock.json index 3fea95c81..67ac9a010 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3082,6 +3082,53 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/@nestjs/schematics/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@nestjs/schematics/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nestjs/schematics/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nestjs/schematics/node_modules/rxjs": { "version": "7.8.1", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", @@ -3317,6 +3364,7 @@ "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, "optional": true, "engines": { "node": ">=14" diff --git a/src/casl/casl-ability.factory.ts b/src/casl/casl-ability.factory.ts index d4b181cad..83a6ccabd 100644 --- a/src/casl/casl-ability.factory.ts +++ b/src/casl/casl-ability.factory.ts @@ -23,6 +23,7 @@ import { OrigDatablock } from "src/origdatablocks/schemas/origdatablock.schema"; import { Policy } from "src/policies/schemas/policy.schema"; import { ProposalClass } from "src/proposals/schemas/proposal.schema"; import { PublishedData } from "src/published-data/schemas/published-data.schema"; +import { PublishedDataStatus } from "src/published-data/interfaces/published-data.interface"; import { SampleClass } from "src/samples/schemas/sample.schema"; import { UserIdentity } from "src/users/schemas/user-identity.schema"; import { UserSettings } from "src/users/schemas/user-settings.schema"; @@ -1093,24 +1094,56 @@ export class CaslAbilityFactory { } publishedDataEndpointAccess(user: JWTUser) { - const { can, build } = new AbilityBuilder( + const { can, cannot, build } = new AbilityBuilder( createMongoAbility, ); - if (user) { - can(Action.Read, PublishedData); - can(Action.Update, PublishedData); + + // Unauthenticated users can only read publicly accessible records + if (!user) { + can(Action.Read, PublishedData, { + $or: [ + { ownerGroup: { $exists: false } }, + { status: PublishedDataStatus.PUBLIC }, + { status: PublishedDataStatus.REGISTERED }, + { status: PublishedDataStatus.AMENDED }, + ], + }); + cannot(Action.Create, PublishedData); + cannot(Action.Update, PublishedData); + cannot(Action.Delete, PublishedData); + } else { + // Authenticated users + // Read access: public records (PUBLIC, REGISTERED, AMENDED) OR legacy (no ownerGroup) OR private records where user is owner + can(Action.Read, PublishedData, { + $or: [ + { ownerGroup: { $exists: false } }, + { status: { $in: [PublishedDataStatus.PUBLIC, PublishedDataStatus.REGISTERED, PublishedDataStatus.AMENDED] } }, + { $and: [ + { status: PublishedDataStatus.PRIVATE }, + { ownerGroup: { $in: user.currentGroups } }, + ] }, + ], + }); + + // Create access: all authenticated users can create can(Action.Create, PublishedData); - } - if ( - user && - user.currentGroups.some((g) => this.accessGroups?.delete.includes(g)) - ) { - /* - / user that belongs to any of the group listed in DELETE_GROUPS - */ - can(Action.Delete, PublishedData); + // Update access: owner of the record OR legacy records (no ownerGroup) + can(Action.Update, PublishedData, { + $or: [ + { ownerGroup: { $in: user.currentGroups } }, + { ownerGroup: { $exists: false } }, + ], + }); + + // Delete access: only delete group members + if (user.currentGroups.some((g) => this.accessGroups?.delete.includes(g))) { + can(Action.Delete, PublishedData); + } else { + cannot(Action.Delete, PublishedData); + } } + return build({ detectSubjectType: (item) => item.constructor as ExtractSubjectType, diff --git a/src/published-data/dto/update-published-data.v4.dto.ts b/src/published-data/dto/update-published-data.v4.dto.ts index ad15dcee9..6fc02a8bf 100644 --- a/src/published-data/dto/update-published-data.v4.dto.ts +++ b/src/published-data/dto/update-published-data.v4.dto.ts @@ -6,50 +6,97 @@ import { IsOptional, IsString, } from "class-validator"; -import { PartialType } from "@nestjs/swagger"; +import { + ApiProperty, + ApiPropertyOptional, + ApiTags, + PartialType, +} from "@nestjs/swagger"; import { PublishedDataStatus } from "../interfaces/published-data.interface"; +import { OwnableDto } from "src/common/dto/ownable.dto"; -export class UpdatePublishedDataV4Dto { - /** - * A name or title by which a resource is known. This field has the semantics of Dublin Core - * [dcmi:title](https://www.dublincore.org/specifications/dublin-core/dcmi-terms/terms/title/) - * and [DataCite title](https://datacite-metadata-schema.readthedocs.io/en/4.6/properties/title/). - */ +@ApiTags("publishedData") +export class UpdatePublishedDataV4Dto extends OwnableDto { + @ApiProperty({ + type: String, + required: true, + description: + "A name or title by which a resource is known. This field has the semantics of Dublin Core" + + " [dcmi:title](https://www.dublincore.org/specifications/dublin-core/dcmi-terms/terms/title/)" + + " and [DataCite title](https://datacite-metadata-schema.readthedocs.io/en/4.6/properties/title/).", + }) @IsString() readonly title: string; - /** - * A brief description of the resource and the context in which the resource was created. This field has the semantics of - * [DataCite description](https://datacite-metadata-schema.readthedocs.io/en/4.6/properties/description/) - * with [Abstract](https://datacite-metadata-schema.readthedocs.io/en/4.6/appendices/appendix-1/descriptionType/#abstract). - */ + @ApiProperty({ + type: String, + required: true, + description: + "A brief description of the resource and the context in which the resource was created. This field has the semantics" + + " of [DataCite description](https://datacite-metadata-schema.readthedocs.io/en/4.6/properties/description/)" + + " with [Abstract descriptionType](https://datacite-metadata-schema.readthedocs.io/en/4.6/appendices/appendix-1/descriptionType/#abstract).", + }) @IsString() readonly abstract: string; - /** - * Array of one or more Dataset persistent identifier (pid) values that make up the published data. - */ + @ApiProperty({ + type: [String], + required: true, + description: + "Array of one or more datasets' persistent identifier values that" + + " are part of this published data record.", + }) @IsArray() @IsString({ each: true }) readonly datasetPids: string[]; - /** - * Time when doi is successfully registered - */ + @ApiPropertyOptional({ + type: [String], + description: + "Array of one or more proposal identifier values that " + + "are part of this published data record.", + }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + readonly proposalIds?: string[]; + + @ApiPropertyOptional({ + type: [String], + description: + "Array of one or more samples identifier values that " + + "are part of this published data record.", + }) + @IsOptional() + @IsArray() + @IsString({ each: true }) + readonly sampleIds?: string[]; + + @ApiProperty({ + type: Date, + required: false, + description: "Time when doi is successfully registered with registrar", + }) @IsDateString() @IsOptional() readonly registeredTime?: Date; - /** - * Indication of position in publication workflow e.g. registred, private, public - */ + @ApiProperty({ + enum: PublishedDataStatus, + description: + "Indication of position in publication workflow e.g. registred, private, public", + }) @IsEnum(PublishedDataStatus) @IsOptional() readonly status?: string; - /** - * JSON object containing the metadata. This will cover most optional fields of the DataCite schema, and will require a mapping from metadata subfields to DataCite Schema definitions. - */ + @ApiProperty({ + type: Object, + required: false, + default: {}, + description: + "JSON object containing the metadata. This will cover most optional fields of the DataCite schema, and will require a mapping from metadata subfields to DataCite Schema definitions", + }) @IsObject() @IsOptional() readonly metadata?: Record; diff --git a/src/published-data/schemas/published-data.schema.ts b/src/published-data/schemas/published-data.schema.ts index 264f264cf..a476df811 100644 --- a/src/published-data/schemas/published-data.schema.ts +++ b/src/published-data/schemas/published-data.schema.ts @@ -1,10 +1,10 @@ import { Prop, Schema, SchemaFactory } from "@nestjs/mongoose"; import { ApiProperty } from "@nestjs/swagger"; import { Document } from "mongoose"; -import { QueryableClass } from "src/common/schemas/queryable.schema"; import { v4 as uuidv4 } from "uuid"; import { PublishedDataStatus } from "../interfaces/published-data.interface"; import crypto from "crypto"; +import { OwnableClass } from "src/common/schemas/ownable.schema"; export type PublishedDataDocument = PublishedData & Document; @@ -15,7 +15,7 @@ export type PublishedDataDocument = PublishedData & Document; }, timestamps: true, }) -export class PublishedData extends QueryableClass { +export class PublishedData extends OwnableClass { @Prop({ type: String, unique: true, @@ -104,16 +104,36 @@ export class PublishedData extends QueryableClass { type: [String], required: true, description: - "Array of one or more Dataset persistent identifier (pid) values that" + - " make up the published data.", + "Array of one or more datasets' persistent identifier values that" + + " are part of the published data record.", }) @Prop({ type: [String], required: true }) datasetPids: string[]; + @ApiProperty({ + type: [String], + required: false, + description: + "Array of one or more proposals identifier values that" + + " are part of this published data record.", + }) + @Prop({ type: [String], required: false }) + proposalIds?: string[]; + + @ApiProperty({ + type: [String], + required: false, + description: + "Array of one or more samples identifier values that" + + " are part of this published data record.", + }) + @Prop({ type: [String], required: false }) + sampleIds?: string[]; + @ApiProperty({ type: Date, required: false, - description: "Time when doi is successfully registered", + description: "Time when doi is successfully registered with registrar", }) @Prop({ type: Date, index: true, required: false }) registeredTime?: Date; diff --git a/test/PublishedData.js b/test/PublishedData_V3.js similarity index 90% rename from test/PublishedData.js rename to test/PublishedData_V3.js index e8a1cb89c..e38ce3a9c 100644 --- a/test/PublishedData.js +++ b/test/PublishedData_V3.js @@ -5,7 +5,6 @@ const sandbox = require("sinon").createSandbox(); let accessTokenArchiveManager = null, accessTokenAdminIngestor = null, - idOrigDatablock = null, pid = null, pidnonpublic = null, @@ -28,7 +27,7 @@ const nonpublictestdataset = { ownerGroup: "examplenonpublicgroup", }; -describe("1600: PublishedData: Test of access to published data", () => { +describe("1600: PublishedData: Test of access to published data V3", () => { before(async () => { db.collection("Dataset").deleteMany({}); db.collection("PublishedData").deleteMany({}); @@ -58,23 +57,53 @@ describe("1600: PublishedData: Test of access to published data", () => { .expect(TestData.EntryCreatedStatusCode) .expect("Content-Type", /json/) .then((res) => { - res.body.should.have.property("affiliation").and.equal(publishedData.affiliation); - res.body.should.have.property("creator").and.deep.equal(publishedData.creator); - res.body.should.have.property("publisher").and.equal(publishedData.publisher); - res.body.should.have.property("publicationYear").and.equal(publishedData.publicationYear); + res.body.should.have + .property("affiliation") + .and.equal(publishedData.affiliation); + res.body.should.have + .property("creator") + .and.deep.equal(publishedData.creator); + res.body.should.have + .property("publisher") + .and.equal(publishedData.publisher); + res.body.should.have + .property("publicationYear") + .and.equal(publishedData.publicationYear); res.body.should.have.property("title").and.equal(publishedData.title); res.body.should.have.property("url").and.equal(publishedData.url); - res.body.should.have.property("abstract").and.equal(publishedData.abstract); - res.body.should.have.property("dataDescription").and.equal(publishedData.dataDescription); - res.body.should.have.property("resourceType").and.equal(publishedData.resourceType); - res.body.should.have.property("numberOfFiles").and.equal(publishedData.numberOfFiles); - res.body.should.have.property("sizeOfArchive").and.equal(publishedData.sizeOfArchive); - res.body.should.have.property("pidArray").and.deep.equal(publishedData.pidArray); - res.body.should.have.property("authors").and.deep.equal(publishedData.authors); - res.body.should.have.property("scicatUser").and.equal(publishedData.scicatUser); - res.body.should.have.property("thumbnail").and.equal(publishedData.thumbnail); - res.body.should.have.property("relatedPublications").and.deep.equal(publishedData.relatedPublications); - res.body.should.have.property("downloadLink").and.equal(publishedData.downloadLink); + res.body.should.have + .property("abstract") + .and.equal(publishedData.abstract); + res.body.should.have + .property("dataDescription") + .and.equal(publishedData.dataDescription); + res.body.should.have + .property("resourceType") + .and.equal(publishedData.resourceType); + res.body.should.have + .property("numberOfFiles") + .and.equal(publishedData.numberOfFiles); + res.body.should.have + .property("sizeOfArchive") + .and.equal(publishedData.sizeOfArchive); + res.body.should.have + .property("pidArray") + .and.deep.equal(publishedData.pidArray); + res.body.should.have + .property("authors") + .and.deep.equal(publishedData.authors); + res.body.should.have + .property("scicatUser") + .and.equal(publishedData.scicatUser); + res.body.should.have + .property("thumbnail") + .and.equal(publishedData.thumbnail); + res.body.should.have + .property("relatedPublications") + .and.deep.equal(publishedData.relatedPublications); + res.body.should.have + .property("downloadLink") + .and.equal(publishedData.downloadLink); res.body.should.have.property("status").and.equal(publishedData.status); doi = encodeURIComponent(res.body["doi"]); }); @@ -149,7 +178,7 @@ describe("1600: PublishedData: Test of access to published data", () => { .get("/api/v3/PublishedData/" + doi) .set("Accept", "application/json") .set({ Authorization: `Bearer ${accessTokenAdminIngestor}` }) - .expect(TestData.SuccessfulGetStatusCode) + .expect(TestData.SuccessfulPostStatusCode) .expect("Content-Type", /json/) .then((res) => { res.body.should.have.property("status").and.equal("registered"); @@ -216,14 +245,18 @@ describe("1600: PublishedData: Test of access to published data", () => { it("0066: should fetch published data with filter", async () => { const filter = { where: { creator: "New Creator" } }; await request(appUrl) - .get(`/api/v3/PublishedData?filter=${encodeURIComponent(JSON.stringify(filter))}`) + .get( + `/api/v3/PublishedData?filter=${encodeURIComponent(JSON.stringify(filter))}`, + ) .set("Accept", "application/json") .set({ Authorization: `Bearer ${accessTokenAdminIngestor}` }) .expect(TestData.SuccessfulGetStatusCode) .expect("Content-Type", /json/) .then((res) => { res.body.length.should.equal(1); - res.body[0].should.have.property("creator").and.deep.equal(["New Creator"]); + res.body[0].should.have + .property("creator") + .and.deep.equal(["New Creator"]); res.body[0].should.have.property("thumbnail"); }); return request(appUrl) diff --git a/test/PublishedDataV4.js b/test/PublishedData_V4.js similarity index 98% rename from test/PublishedDataV4.js rename to test/PublishedData_V4.js index 12c27d3f6..38a55497e 100644 --- a/test/PublishedDataV4.js +++ b/test/PublishedData_V4.js @@ -38,7 +38,7 @@ const nonpublictestdataset = { ownerGroup: "examplenonpublicgroup", }; -describe("1600: PublishedDataV4: Test of access to published data v4 endpoints", () => { +describe("1610: PublishedDataV4: Test of access to published data v4 endpoints", () => { before(async () => { db.collection("Dataset").deleteMany({}); db.collection("PublishedData").deleteMany({}); @@ -100,7 +100,9 @@ describe("1600: PublishedDataV4: Test of access to published data v4 endpoints", .expect(TestData.EntryCreatedStatusCode) .expect("Content-Type", /json/) .then((res) => { - res.body.should.have.property("title").and.be.string; + res.body.should.have + .property("title") + .and.be.string(publishedData.title); res.body.should.have.property("metadata"); res.body.metadata.should.have.property("publisher"); res.body.should.have.property("status").and.equal(defaultStatus); diff --git a/test/TestData.js b/test/TestData.js index 0aba16518..80c881ec9 100644 --- a/test/TestData.js +++ b/test/TestData.js @@ -1358,6 +1358,7 @@ const TestData = { landingPage: "doi.ess.eu/detail/", }, datasetPids: [], + ownerGroup: "admin", }, InstrumentCorrect1: {