Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions migrations/20260605150000-add-ownership-to-published-data.js
Original file line number Diff line number Diff line change
@@ -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");
},
};
48 changes: 48 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

59 changes: 46 additions & 13 deletions src/casl/casl-ability.factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
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";
Expand Down Expand Up @@ -1093,24 +1094,56 @@
}

publishedDataEndpointAccess(user: JWTUser) {
const { can, build } = new AbilityBuilder(
const { can, cannot, build } = new AbilityBuilder(
createMongoAbility<PossibleAbilities, Conditions>,
);
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] } },

Check failure on line 1120 in src/casl/casl-ability.factory.ts

View workflow job for this annotation

GitHub Actions / eslint

Replace `·status:·{·$in:·[PublishedDataStatus.PUBLIC,·PublishedDataStatus.REGISTERED,·PublishedDataStatus.AMENDED]·}` with `⏎············status:·{⏎··············$in:·[⏎················PublishedDataStatus.PUBLIC,⏎················PublishedDataStatus.REGISTERED,⏎················PublishedDataStatus.AMENDED,⏎··············],⏎············},⏎·········`
{ $and: [

Check failure on line 1121 in src/casl/casl-ability.factory.ts

View workflow job for this annotation

GitHub Actions / eslint

Insert `⏎···········`
{ status: PublishedDataStatus.PRIVATE },

Check failure on line 1122 in src/casl/casl-ability.factory.ts

View workflow job for this annotation

GitHub Actions / eslint

Replace `············` with `··············`
{ ownerGroup: { $in: user.currentGroups } },

Check failure on line 1123 in src/casl/casl-ability.factory.ts

View workflow job for this annotation

GitHub Actions / eslint

Insert `··`
] },

Check failure on line 1124 in src/casl/casl-ability.factory.ts

View workflow job for this annotation

GitHub Actions / eslint

Replace `]` with `··],⏎·········`
],
});

// 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))) {

Check failure on line 1140 in src/casl/casl-ability.factory.ts

View workflow job for this annotation

GitHub Actions / eslint

Replace `user.currentGroups.some((g)·=>·this.accessGroups?.delete.includes(g))` with `⏎········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<Subjects>,
Expand Down
95 changes: 71 additions & 24 deletions src/published-data/dto/update-published-data.v4.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
Expand Down
30 changes: 25 additions & 5 deletions src/published-data/schemas/published-data.schema.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading