Skip to content

Commit

Permalink
Merge pull request #45 from icefoganalytics/issue-44/user-dataset-des…
Browse files Browse the repository at this point in the history
…cription-read-page

User: Dataset Description Read Page
  • Loading branch information
klondikemarlen authored Feb 21, 2024
2 parents 662761b + 8a503df commit ea90a22
Show file tree
Hide file tree
Showing 43 changed files with 2,227 additions and 172 deletions.
Binary file modified _Design/Entity Relationship Diagrams.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 0 additions & 1 deletion _Design/Entity Relationship Diagrams.wsd
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,6 @@ entity "datasets" {
* description : nvarchar(MAX)
subscription_url : nvarchar(1000)
subscription_access_code : nvarchar(255)
is_subscribable : bit
is_spatial_data : bit
is_live_data : bit
terms_of_use : nvarchar(MAX)
Expand Down
60 changes: 59 additions & 1 deletion api/src/controllers/access-requests-controller.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { WhereOptions } from "sequelize"
import { isNil } from "lodash"

import { AccessRequest } from "@/models"
import { AccessRequest, Dataset } from "@/models"
import { TableSerializer } from "@/serializers/access-requests"
import { CreateService } from "@/services/access-requests"
import { AccessRequestsPolicy } from "@/policies"
import { type AccessRequestForCreate } from "@/policies/access-requests-policy"

import BaseController from "@/controllers/base-controller"

Expand Down Expand Up @@ -38,6 +42,60 @@ export class AccessRequestsController extends BaseController {
.json({ message: `Failed to serialize access requests: ${error}` })
}
}

async create() {
const accessRequest = await this.buildAccessRequest()
if (isNil(accessRequest)) {
return this.response.status(404).json({ message: "Access request could not be built." })
}

const policy = this.buildPolicy(accessRequest)
if (!policy.create()) {
return this.response
.status(403)
.json({ message: "You are not authorized to make access requests for this dataset." })
}

const permittedAttributes = policy.permitAttributesForCreate(this.request.body)
try {
const accessRequest = await CreateService.perform(permittedAttributes, this.currentUser)
return this.response.status(201).json({ accessRequest })
} catch (error) {
return this.response.status(422).json({ message: `Access request creation failed: ${error}` })
}
}

private async buildAccessRequest() {
const accessRequest = AccessRequest.build(this.request.body)
if (isNil(accessRequest)) return null

const { datasetId, accessGrantId } = this.request.body
if (isNil(datasetId) || isNil(accessGrantId)) return null

const dataset = await Dataset.findByPk(datasetId, {
include: [
{
association: "owner",
include: [
{
association: "groupMembership",
include: ["department", "division", "branch", "unit"],
},
],
},
"accessGrants",
],
})
if (isNil(dataset)) return null

accessRequest.dataset = dataset

return accessRequest as AccessRequestForCreate
}

private buildPolicy(accessRequest: AccessRequestForCreate) {
return new AccessRequestsPolicy(this.currentUser, accessRequest)
}
}

export default AccessRequestsController
2 changes: 1 addition & 1 deletion api/src/controllers/access-requests/deny-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export class DenyController extends BaseController {
.json({ message: "You are not authorized to deny access requests on this dataset." })
}

const permittedAttributes = policy.permitAttributes(this.request.body)
const permittedAttributes = policy.permitAttributesForUpdate(this.request.body)
try {
const updatedAccessRequest = await DenyService.perform(
accessRequest,
Expand Down
42 changes: 34 additions & 8 deletions api/src/controllers/datasets-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,18 @@ import { isNil } from "lodash"
import { Dataset } from "@/models"
import { DatasetsPolicy } from "@/policies"
import { CreateService, UpdateService } from "@/services/datasets"
import { TableSerializer } from "@/serializers/datasets"
import { ShowSerializer, TableSerializer } from "@/serializers/datasets"

import BaseController from "@/controllers/base-controller"

export class DatasetsController extends BaseController {
async index() {
const where = this.query.where as WhereOptions<Dataset>

// TODO: add query scoping, filter out datasets where the user does not have any access
const scopedDatasets = DatasetsPolicy.applyScope(Dataset, this.currentUser)

const totalCount = await Dataset.count({ where })
const datasets = await Dataset.findAll({
const totalCount = await scopedDatasets.count({ where })
const datasets = await scopedDatasets.findAll({
where,
limit: this.pagination.limit,
offset: this.pagination.offset,
Expand All @@ -40,8 +40,12 @@ export class DatasetsController extends BaseController {
],
})

const serializedDatasets = TableSerializer.perform(datasets, this.currentUser)
return this.response.json({ datasets: serializedDatasets, totalCount })
try {
const serializedDatasets = TableSerializer.perform(datasets, this.currentUser)
return this.response.json({ datasets: serializedDatasets, totalCount })
} catch (error) {
return this.response.status(500).json({ message: `Dataset serialization failed: ${error}` })
}
}

async show() {
Expand All @@ -57,7 +61,15 @@ export class DatasetsController extends BaseController {
.json({ message: "You are not authorized to view this dataset." })
}

return this.response.status(200).json({ dataset })
try {
const serializedDataset = ShowSerializer.perform(dataset, this.currentUser)
return this.response.status(200).json({
dataset: serializedDataset,
policy,
})
} catch (error) {
return this.response.status(500).json({ message: `Dataset serialization failed: ${error}` })
}
}

async create() {
Expand Down Expand Up @@ -106,7 +118,21 @@ export class DatasetsController extends BaseController {

private async loadDataset(): Promise<Dataset | null> {
const dataset = await Dataset.findBySlugOrPk(this.params.datasetIdOrSlug, {
include: ["owner", "creator", "stewardship"],
include: [
{
association: "owner",
include: [
{
association: "groupMembership",
include: ["department", "division", "branch", "unit"],
},
],
},
"creator",
"stewardship",
"accessGrants",
"accessRequests",
],
})

return dataset
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { isNil } from "lodash"

import { Role } from "@/models"
import { RoleTypes } from "@/models/role"

import BaseController from "@/controllers/base-controller"

// Keep in sync with web/src/components/qa-scenarios/QaScenariosCard.vue
const ORDERED_ROLE_TYPES = [
RoleTypes.USER,
RoleTypes.DATA_OWNER,
RoleTypes.BUSINESS_ANALYST,
RoleTypes.SYSTEM_ADMIN,
]

export class CycleUserRoleTypeController extends BaseController {
async create() {
console.warn("This should not be running in production!")

try {
const newRoleType = this.determineNextRole()
await this.replaceUserRolesWith(newRoleType)
return this.response.status(201).json({ message: `Cycled user role to ${newRoleType}` })
} catch (error) {
return this.response.status(422).json({ message: `Could not cycle user role: ${error}` })
}
}

private async replaceUserRolesWith(newRoleType: RoleTypes): Promise<void> {
await Role.destroy({ where: { userId: this.currentUser.id }, force: true })
await Role.create({
userId: this.currentUser.id,
role: newRoleType,
})
return
}

private determineNextRole(): RoleTypes {
if (isNil(this.currentUser)) {
throw new Error("No current user")
}

const { roleTypes } = this.currentUser
const currentRoleType = roleTypes[0]
if (isNil(currentRoleType)) {
throw new Error("Current user has no roles")
}

const currentIndex = ORDERED_ROLE_TYPES.indexOf(currentRoleType)
const nextIndex = (currentIndex + 1) % 4
return ORDERED_ROLE_TYPES[nextIndex]
}
}

export default CycleUserRoleTypeController
1 change: 1 addition & 0 deletions api/src/controllers/qa-scenarios/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { LinkRandomTagsController } from "./link-random-tags-controller"
export { ApplyRandomAccessGrantsController } from "./apply-random-access-grants-controller"
export { AddRandomAccessRequestsController } from "./add-random-access-requests-controller"
export { CycleUserRoleTypeController } from "./cycle-user-role-type-controller"
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { DataTypes } from "sequelize"

import type { Migration } from "@/db/umzug"

export const up: Migration = async ({ context: queryInterface }) => {
await queryInterface.removeColumn("datasets", "is_subscribable")
}

export const down: Migration = async ({ context: queryInterface }) => {
await queryInterface.addColumn("datasets", "is_subscribable", {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
})
}
14 changes: 8 additions & 6 deletions api/src/models/dataset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import DatasetStewardship from "@/models/dataset-stewardship"
import Tag from "@/models/tag"
import Tagging, { TaggableTypes } from "@/models/tagging"
import User from "@/models/user"
import { mostPermissiveAccessGrantFor, accessibleViaAccessGrantsBy } from "@/models/datasets"

import BaseModel from "@/models/base-model"

Expand All @@ -52,7 +53,6 @@ export class Dataset extends BaseModel<InferAttributes<Dataset>, InferCreationAt
declare description: string
declare subscriptionUrl: CreationOptional<string | null>
declare subscriptionAccessCode: CreationOptional<string | null>
declare isSubscribable: CreationOptional<boolean>
declare isSpatialData: CreationOptional<boolean>
declare isLiveData: CreationOptional<boolean>
declare termsOfUse: CreationOptional<string | null>
Expand Down Expand Up @@ -211,6 +211,10 @@ export class Dataset extends BaseModel<InferAttributes<Dataset>, InferCreationAt
as: "accessGrants",
})
}

public mostPermissiveAccessGrantFor(user: User): NonAttribute<AccessGrant | null> {
return mostPermissiveAccessGrantFor(this, user)
}
}

Dataset.init(
Expand Down Expand Up @@ -257,11 +261,6 @@ Dataset.init(
type: DataTypes.STRING,
allowNull: true,
},
isSubscribable: {
type: DataTypes.BOOLEAN,
allowNull: false,
defaultValue: false,
},
isSpatialData: {
type: DataTypes.BOOLEAN,
allowNull: false,
Expand Down Expand Up @@ -335,6 +334,9 @@ Dataset.init(
},
},
],
scopes: {
accessibleViaAccessGrantsBy,
},
}
)

Expand Down
93 changes: 93 additions & 0 deletions api/src/models/datasets/accessible-via-access-grants-by.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { Op, WhereAttributeHash, literal } from "sequelize"
import { isNil } from "lodash"

import User from "@/models/user"

const NON_EXISTENT_ID = -1

// TODO: make this less fragile and more easily testable
export function accessibleViaAccessGrantsBy(user: User): {
where: WhereAttributeHash
} {
const { groupMembership } = user
if (isNil(groupMembership)) {
throw new Error("User must have groupMembership to use accessibleViaAccessGrantsBy")
}

const departmentId = groupMembership.departmentId || NON_EXISTENT_ID
const divisionId = groupMembership.divisionId || NON_EXISTENT_ID
const branchId = groupMembership.branchId || NON_EXISTENT_ID
const unitId = groupMembership.unitId || NON_EXISTENT_ID

const query = `
(
SELECT DISTINCT
datasets.id
FROM
datasets
INNER JOIN user_group_memberships AS owner_group_membership ON
owner_group_membership.deleted_at IS NULL
AND owner_group_membership.user_id = datasets.owner_id
INNER JOIN access_grants ON
access_grants.deleted_at IS NULL
AND access_grants.dataset_id = datasets.id
WHERE
(
access_grants.access_type IN ('open_access', 'self_serve_access', 'screened_access')
AND access_grants.grant_level = 'government_wide'
)
OR
(
access_grants.access_type IN ('open_access', 'self_serve_access', 'screened_access')
AND access_grants.grant_level = 'department'
AND owner_group_membership.department_id IS NOT NULL
AND owner_group_membership.department_id = ${departmentId}
)
OR
(
access_grants.access_type IN ('open_access', 'self_serve_access', 'screened_access')
AND access_grants.grant_level = 'division'
AND owner_group_membership.department_id IS NOT NULL
AND owner_group_membership.division_id IS NOT NULL
AND owner_group_membership.department_id = ${departmentId}
AND owner_group_membership.division_id = ${divisionId}
)
OR
(
access_grants.access_type IN ('open_access', 'self_serve_access', 'screened_access')
AND access_grants.grant_level = 'branch'
AND owner_group_membership.department_id IS NOT NULL
AND owner_group_membership.division_id IS NOT NULL
AND owner_group_membership.branch_id IS NOT NULL
AND owner_group_membership.department_id = ${departmentId}
AND owner_group_membership.division_id = ${divisionId}
AND owner_group_membership.branch_id = ${branchId}
)
OR
(
access_grants.access_type IN ('open_access', 'self_serve_access', 'screened_access')
AND access_grants.grant_level = 'unit'
AND owner_group_membership.department_id IS NOT NULL
AND owner_group_membership.division_id IS NOT NULL
AND owner_group_membership.branch_id IS NOT NULL
AND owner_group_membership.unit_id IS NOT NULL
AND owner_group_membership.department_id = ${departmentId}
AND owner_group_membership.division_id = ${divisionId}
AND owner_group_membership.branch_id = ${branchId}
AND owner_group_membership.unit_id = ${unitId}
)
)
`
.replace(/\s+/g, " ")
.trim()

return {
where: {
id: {
[Op.in]: literal(query),
},
},
}
}

export default accessibleViaAccessGrantsBy
2 changes: 2 additions & 0 deletions api/src/models/datasets/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { mostPermissiveAccessGrantFor } from "./most-permissive-access-grant-for"
export { accessibleViaAccessGrantsBy } from "./accessible-via-access-grants-by"
Loading

0 comments on commit ea90a22

Please sign in to comment.