Skip to content

Commit

Permalink
Project visibility (#4929)
Browse files Browse the repository at this point in the history
* add access level to db

* fix white space 🤦

* fix migrations

* migrations

* project visibility

* merge

* read project access level

* remove unneccesary change

* refresh status

* add badges

* 404 page

* 404 page

* fix height

* remove import

* fix tests

* fix webpack

* refactor access

* fix tests

* send permissions model to the client

* fix conflict issue

* fix lock

* remove unneeded dep

* split pr

* Revert "send permissions model to the client"

This reverts commit 275a138.

* remove enum

* remove enums

* refactor enum

* allow the creator to access their own projects

* fix import

* fix import

* add tests

* add tests

* add mock

* tests

* tests

* tests

* change to docker

* clear db

* clear order

* fix structure

* fix env

* readme

* env vars

* fix

* fix CR

* cr

* check project ownership

* test

* fix import

* text

* cr fixes

* minor

* add to env sample

* use validators

* imports

* add validators

* change validator

* handle anonymous user id

* don't return a boolean from validators

* unify session id logic

* get only the owner id

* fix leftover

* change validators

* change to optional

* change to optional

* fix db

* tests

* fix owner id

* tests

* minor

* fix tests

* get only owner id

* remove uneeded db call

* fix test

* cr changes

* fix cr

* add strings to env vars

* add tests

* add anonymous test

* add anonymous test

* add validators to routes

* fix access

* cr fixes

* added tests for deleted

* typo

* minor code style changes

* cr fixes

* cr fixes

* remove 'clearDb' function

* fix link issue
  • Loading branch information
liady authored Mar 8, 2024
1 parent 3aec212 commit b94ee92
Show file tree
Hide file tree
Showing 63 changed files with 7,213 additions and 1,909 deletions.
9 changes: 9 additions & 0 deletions server/migrations/008.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
CREATE TABLE if not exists project_access (
id serial,
project_id text not null references project(proj_id) ON DELETE CASCADE,
access_level integer not null default 0,
modified_at timestamp with time zone not null default now()
);

ALTER TABLE ONLY "project_access"
ADD CONSTRAINT "unique_project_access" UNIQUE ("project_id");
3 changes: 2 additions & 1 deletion server/src/Utopia/Web/Database/Migrations.hs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ migrateDatabase verbose includeInitial pool = withResource pool $ \connection ->
, MigrationFile "004.sql" "./migrations/004.sql"
, MigrationFile "005.sql" "./migrations/005.sql"
, MigrationFile "006.sql" "./migrations/006.sql"
, MigrationFile "007.sql" "./migrations/007.sql"
, MigrationFile "007.sql" "./migrations/007.sql"
, MigrationFile "008.sql" "./migrations/008.sql"
]
let initialMigrationCommand = if includeInitial
then [MigrationFile "initial.sql" "./migrations/initial.sql"]
Expand Down
7 changes: 7 additions & 0 deletions utopia-remix/.env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ AUTH0_ENDPOINT=""
AUTH0_CLIENT_ID=""
AUTH0_REDIRECT_URI=""

FGA_STORE_ID=""
FGA_CLIENT_ID=""
FGA_SECRET=""
FGA_API_HOST="api.us1.fga.dev"
FGA_API_TOKEN_ISSUER="fga.us.auth0.com"
FGA_API_AUDIENCE="https://api.us1.fga.dev/"

GITHUB_OAUTH_CLIENT_ID=""
GITHUB_OAUTH_REDIRECT_URL=""

Expand Down
8 changes: 8 additions & 0 deletions utopia-remix/__mocks__/@openfga/sdk.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export function OpenFgaClient() {
return {
write: jest.fn(),
check: jest.fn(),
}
}

export const CredentialsMethod = {}
25 changes: 24 additions & 1 deletion utopia-remix/app/components/projectActionContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ import { contextMenuDropdown, contextMenuItem } from '../styles/contextMenu.css'
import { sprinkles } from '../styles/sprinkles.css'
import {
ProjectWithoutContent,
operationChangeAccess,
operationDelete,
operationDestroy,
operationRename,
operationRestore,
} from '../types'
import { assertNever } from '../util/assertNever'
import { AccessLevel } from '../types'
import { useProjectEditorLink } from '../util/links'
import { useFetcherWithOperation } from '../hooks/useFetcherWithOperation'
import slugify from 'slugify'
Expand All @@ -28,8 +30,10 @@ export const ProjectContextMenu = React.memo(({ project }: { project: ProjectWit
const destroyFetcher = useFetcherWithOperation(project.proj_id, 'destroy')
const restoreFetcher = useFetcherWithOperation(project.proj_id, 'restore')
const renameFetcher = useFetcherWithOperation(project.proj_id, 'rename')
const changeAccessFetcher = useFetcherWithOperation(project.proj_id, 'changeAccess')

const selectedCategory = useProjectsStore((store) => store.selectedCategory)
let accessLevel = project.ProjectAccess?.access_level ?? AccessLevel.PRIVATE

const deleteProject = React.useCallback(
(projectId: string) => {
Expand Down Expand Up @@ -78,6 +82,16 @@ export const ProjectContextMenu = React.memo(({ project }: { project: ProjectWit
[renameFetcher],
)

const changeAccessLevel = React.useCallback(
(projectId: string, accessLevel: AccessLevel) => {
changeAccessFetcher.submit(
operationChangeAccess(project, accessLevel),
{ accessLevel: accessLevel.toString() },
{ method: 'POST', action: `/internal/projects/${projectId}/access` },
)
},
[changeAccessFetcher],
)
const projectEditorLink = useProjectEditorLink()

const menuEntries = React.useMemo((): ContextMenuEntry[] => {
Expand Down Expand Up @@ -120,6 +134,15 @@ export const ProjectContextMenu = React.memo(({ project }: { project: ProjectWit
deleteProject(project.proj_id)
},
},
{
text: accessLevel === AccessLevel.PUBLIC ? 'Make Private' : 'Make Public',
onClick: (project) => {
changeAccessLevel(
project.proj_id,
accessLevel === AccessLevel.PUBLIC ? AccessLevel.PRIVATE : AccessLevel.PUBLIC,
)
},
},
]
case 'trash':
return [
Expand All @@ -140,7 +163,7 @@ export const ProjectContextMenu = React.memo(({ project }: { project: ProjectWit
default:
assertNever(selectedCategory)
}
}, [selectedCategory])
}, [selectedCategory, accessLevel, projectEditorLink])

return (
<DropdownMenu.Portal>
Expand Down
7 changes: 7 additions & 0 deletions utopia-remix/app/env.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ export const ServerEnvironment = {
AUTH0_ENDPOINT: optionalEnv('AUTH0_ENDPOINT', '<AUTH0_ENDPOINT>'),
AUTH0_CLIENT_ID: optionalEnv('AUTH0_CLIENT_ID', '<AUTH0_CLIENT_ID>'),
AUTH0_REDIRECT_URI: optionalEnv('AUTH0_REDIRECT_URI', '<AUTH0_REDIRECT_URI>'),
// FGA Credentials
FGA_STORE_ID: optionalEnv('FGA_STORE_ID', '<FGA_STORE_ID>'),
FGA_CLIENT_ID: optionalEnv('FGA_CLIENT_ID', '<FGA_CLIENT_ID>'),
FGA_SECRET: optionalEnv('FGA_SECRET', '<FGA_SECRET>'),
FGA_API_HOST: optionalEnv('FGA_API_HOST', '<FGA_API_HOST>'),
FGA_API_TOKEN_ISSUER: optionalEnv('FGA_API_TOKEN_ISSUER', '<FGA_API_TOKEN_ISSUER>'),
FGA_API_AUDIENCE: optionalEnv('FGA_API_AUDIENCE', '<FGA_API_AUDIENCE>'),
// Github OAuth credentials
GITHUB_OAUTH_CLIENT_ID: optionalEnv('GITHUB_OAUTH_CLIENT_ID', ''),
GITHUB_OAUTH_REDIRECT_URL: optionalEnv('GITHUB_OAUTH_REDIRECT_URL', ''),
Expand Down
38 changes: 38 additions & 0 deletions utopia-remix/app/handlers/validators.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { AccessValidator, ensure, getUser } from '../util/api.server'
import { UserProjectPermission } from '../types'
import { Params } from '@remix-run/react'
import { Status } from '../util/statusCodes'
import { hasUserProjectPermission } from '../services/permissionsService.server'
import { getProjectOwnerById } from '../models/project.server'

export function validateProjectAccess(
permission: UserProjectPermission,
{
errorMessage,
status,
getProjectId,
includeDeleted = false,
}: {
errorMessage?: string
status?: number
getProjectId: (params: Params<string>) => string | null | undefined
includeDeleted?: boolean
},
): AccessValidator {
return async function (req: Request, params: Params<string>) {
const projectId = getProjectId(params)
ensure(projectId != null, 'project id is null', Status.BAD_REQUEST)

const ownerId = await getProjectOwnerById({ id: projectId }, { includeDeleted: includeDeleted })
ensure(ownerId != null, `Project ${projectId} not found or has no owner`, Status.NOT_FOUND)

const user = await getUser(req)
const userId = user?.user_id ?? null
const isCreator = userId ? ownerId === userId : false

const allowed = isCreator || (await hasUserProjectPermission(projectId, userId, permission))
ensure(allowed, errorMessage ?? 'Unauthorized Access', status ?? Status.UNAUTHORIZED)
}
}

export const ALLOW: AccessValidator = async (request: Request, params: Params<string>) => true
26 changes: 26 additions & 0 deletions utopia-remix/app/models/project.server.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
renameProject,
restoreDeletedProject,
softDeleteProject,
getProjectOwnerById,
} from './project.server'

describe('project model', () => {
Expand Down Expand Up @@ -150,6 +151,31 @@ describe('project model', () => {
})
})

describe('getProjectOwnerById', () => {
beforeEach(async () => {
await createTestUser(prisma, { id: 'bob' })
await createTestUser(prisma, { id: 'alice' })
await createTestProject(prisma, { id: 'foo', ownerId: 'bob' })
await createTestProject(prisma, {
id: 'deleted-project',
ownerId: 'bob',
deleted: true,
})
})
it('returns the project owner', async () => {
const got = await getProjectOwnerById({ id: 'foo' }, { includeDeleted: false })
expect(got).toEqual('bob')
})
it('doesnt return the owner if the project is soft-deleted', async () => {
const got = await getProjectOwnerById({ id: 'deleted-project' }, { includeDeleted: false })
expect(got).toEqual(null)
})
it('returns soft-deleted owner if includeDeleted is true', async () => {
const got = await getProjectOwnerById({ id: 'deleted-project' }, { includeDeleted: true })
expect(got).toEqual('bob')
})
})

describe('restoreDeletedProject', () => {
beforeEach(async () => {
await createTestUser(prisma, { id: 'bob' })
Expand Down
23 changes: 23 additions & 0 deletions utopia-remix/app/models/project.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const selectProjectWithoutContent: Record<keyof ProjectWithoutContent, true> = {
created_at: true,
modified_at: true,
deleted: true,
ProjectAccess: true,
}

export async function listProjects(params: { ownerId: string }): Promise<ProjectWithoutContent[]> {
Expand All @@ -22,6 +23,28 @@ export async function listProjects(params: { ownerId: string }): Promise<Project
})
}

export async function getProjectOwnerById(
params: { id: string },
config: { includeDeleted: boolean } = { includeDeleted: false },
): Promise<string | null> {
let whereOptions: {
proj_id: string
OR?: { deleted: boolean | null }[]
} = {
proj_id: params.id,
}
// if we're not including deleted projects, we need to add a condition to the query
if (!config.includeDeleted) {
whereOptions.OR = [{ deleted: null }, { deleted: false }]
}
// find the project and return the owner_id
const project = await prisma.project.findFirst({
select: { owner_id: true },
where: whereOptions,
})
return project?.owner_id ?? null
}

export async function renameProject(params: {
id: string
userId: string
Expand Down
72 changes: 72 additions & 0 deletions utopia-remix/app/models/projectAccess.server.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
jest.mock('@openfga/sdk')
import { prisma } from '../db.server'
import {
createTestProject,
createTestProjectAccess,
createTestUser,
truncateTables,
} from '../test-util'
import { setProjectAccess } from './projectAccess.server'
import * as permissionsService from '../services/permissionsService.server'

describe('projectAccess model', () => {
afterAll(async () => {
jest.restoreAllMocks()
})

describe('setProjectAccess', () => {
beforeAll(async () => {
await truncateTables([
prisma.projectAccess,
prisma.projectCollaborator,
prisma.userDetails,
prisma.persistentSession,
prisma.project,
prisma.projectID,
])
})
beforeEach(async () => {
await createTestUser(prisma, { id: 'bob' })
await createTestUser(prisma, { id: 'alice' })
await createTestProject(prisma, { id: 'one', ownerId: 'bob' })
await createTestProject(prisma, { id: 'two', ownerId: 'bob' })
await createTestProjectAccess(prisma, { projectId: 'one', accessLevel: 0 })
await createTestProjectAccess(prisma, { projectId: 'two', accessLevel: 1 })
jest.spyOn(permissionsService, 'setProjectAccess').mockResolvedValue()
})
afterEach(async () => {
await truncateTables([
prisma.projectAccess,
prisma.projectCollaborator,
prisma.userDetails,
prisma.persistentSession,
prisma.project,
prisma.projectID,
])
jest.spyOn(permissionsService, 'setProjectAccess').mockRestore()
})
it('sets the access level for a project', async () => {
await setProjectAccess({ projectId: 'one', accessLevel: 1 })
const projectAccess = await prisma.projectAccess.findFirst({
where: { project_id: 'one' },
})
expect(projectAccess?.access_level).toEqual(1)
expect(permissionsService.setProjectAccess).toHaveBeenCalledWith('one', 1)
})
it('updates the modified_at field', async () => {
await setProjectAccess({ projectId: 'one', accessLevel: 1 })
const projectAccess = await prisma.projectAccess.findFirst({
where: { project_id: 'one' },
})
expect(projectAccess?.modified_at).not.toBeNull()
})
it('sets the access level on the project model itself', async () => {
await setProjectAccess({ projectId: 'one', accessLevel: 1 })
const project = await prisma.project.findFirst({
where: { proj_id: 'one' },
include: { ProjectAccess: true },
})
expect(project?.ProjectAccess?.access_level).toEqual(1)
})
})
})
26 changes: 26 additions & 0 deletions utopia-remix/app/models/projectAccess.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { AccessLevel } from '../types'
import { prisma } from '../db.server'
import * as permissionsService from '../services/permissionsService.server'

export async function setProjectAccess(params: {
projectId: string
accessLevel: AccessLevel
}): Promise<void> {
await prisma.$transaction(async (tx) => {
await tx.projectAccess.upsert({
where: {
project_id: params.projectId,
},
update: {
access_level: params.accessLevel,
modified_at: new Date(),
},
create: {
project_id: params.projectId,
access_level: params.accessLevel,
modified_at: new Date(),
},
})
await permissionsService.setProjectAccess(params.projectId, params.accessLevel)
})
}
1 change: 1 addition & 0 deletions utopia-remix/app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { ErrorWithStatus, isErrorWithStatus } from './util/errors'
import { Status, getStatusName } from './util/statusCodes'

import './normalize.css'
import '@radix-ui/themes/styles.css'

declare global {
interface Window {
Expand Down
Loading

0 comments on commit b94ee92

Please sign in to comment.