Skip to content
Merged
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
14 changes: 5 additions & 9 deletions api/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,11 @@ interface GoogleTokens {
token_type: string
}

interface GoogleUserInfo {
export type GoogleUserInfo = {
email: string
name: string
hd?: string
sub: string
picture?: string
given_name?: string
family_name?: string
Expand Down Expand Up @@ -99,20 +100,15 @@ export async function handleGoogleCallback(
// Verify and decode the ID token
await verifyGoogleToken(tokens.id_token)
const userInfo = decodeGoogleJWT(tokens.id_token) as GoogleUserInfo
// Authenticate admin user (creates if doesn't exist)
const userPicture = await savePicture(userInfo.picture)
const adminSessionId = await authenticateOauthUser({
userEmail: userInfo.email,
userFullName: userInfo.name,
userPicture,
})
userInfo.picture &&= await savePicture(userInfo.picture)
const sessionId = await authenticateOauthUser(userInfo)

// Return response with session cookie
return new Response(null, {
status: 302,
headers: {
'Location': ORIGIN,
'Set-Cookie': `session=${adminSessionId}; ${
'Set-Cookie': `session=${sessionId}; ${
Object.entries(GOOGLE_CONFIG.COOKIE_OPTIONS)
.map(([key, value]) => `${key}=${value}`)
.join('; ')
Expand Down
3 changes: 3 additions & 0 deletions api/lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,6 @@ export const CLICKHOUSE_PASSWORD = ENV('CLICKHOUSE_PASSWORD')
export const DB_SCHEMA_REFRESH_MS = Number(
ENV('DB_SCHEMA_REFRESH_MS', `${24 * 60 * 60 * 1000}`),
)

export const STORE_URL = ENV('STORE_URL')
export const STORE_SECRET = ENV('STORE_SECRET')
7 changes: 2 additions & 5 deletions api/lib/google-oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,12 +86,9 @@ export async function verifyGoogleToken(idToken: string) {
export function decodeGoogleJWT(idToken: string) {
const [, payload] = idToken.split('.')
if (!payload) throw new Error('Invalid ID token format')

try {
return JSON.parse(atob(payload)) as {
email: string
name: string
hd?: string
}
return JSON.parse(atob(payload))
} catch {
throw new Error('Invalid ID token payload')
}
Expand Down
21 changes: 21 additions & 0 deletions api/lmdb-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { STORE_SECRET, STORE_URL } from '/api/lib/env.ts'

const headers = { authorization: `Bearer ${STORE_SECRET}` }
export const getOne = async <T>(
path: string,
id: string,
): Promise<T | null> => {
const url = `${STORE_URL}/${path}/${encodeURIComponent(String(id))}`
const res = await fetch(url, { headers })
if (res.status === 404) return null
return res.json()
}

export const get = async <T>(
path: string,
params?: { q?: string; limit?: number; from?: number },
): Promise<T> => {
const q = new URLSearchParams(params as unknown as Record<string, string>)
const res = await fetch(`${STORE_URL}/${path}/?${q}`, { headers })
return res.json()
}
167 changes: 92 additions & 75 deletions api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@ import { makeRouter, route } from '@01edu/api/router'
import type { RequestContext } from '@01edu/api/context'
import { handleGoogleCallback, initiateGoogleAuth } from '/api/auth.ts'
import {
AdminsCollection,
DatabaseSchemasCollection,
DeploymentDef,
DeploymentsCollection,
ProjectsCollection,
TeamDef,
TeamsCollection,
TeamDetailDef,
User,
UserDef,
UsersCollection,
} from './schema.ts'
import {
ARR,
Expand Down Expand Up @@ -39,11 +39,13 @@ import {
updateTableData,
} from '/api/sql.ts'
import { Log } from '@01edu/api/log'
import { get, getOne } from './lmdb-store.ts'

const withUserSession = async ({ cookies }: RequestContext) => {
const session = await decodeSession(cookies.session)
if (!session) throw Error('Missing user session')
return session
const admin = AdminsCollection.get(session.id)
return { ...session, isAdmin: !!admin }
}

const withAdminSession = async (ctx: RequestContext) => {
Expand All @@ -62,12 +64,16 @@ const withDeploymentSession = async (ctx: RequestContext) => {
return dep
}

const userInTeam = (teamId: string, userEmail?: string) => {
if (!userEmail) return false
return TeamsCollection.get(teamId)?.teamMembers.includes(userEmail)
const userInTeam = async (teamId: string, userId?: string) => {
if (!userId) return false
const matches = await getOne<{ id: string }>(
`google/group/${teamId}`,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the key is `google/group/${teamId}/members/${id}` so this should be sufficient to check if he is present in the group, using getOne

userId,
)
return !!matches
}

const withDeploymentTableAccess = (
const withDeploymentTableAccess = async (
ctx: RequestContext & { session: User },
deployment: string,
) => {
Expand All @@ -83,7 +89,7 @@ const withDeploymentTableAccess = (
const project = ProjectsCollection.get(dep.projectId)
if (!project) throw respond.NotFound({ message: 'Project not found' })
if (!project.isPublic && !ctx.session.isAdmin) {
if (!userInTeam(project.teamId, ctx.session.userEmail)) {
if (!(await userInTeam(project.teamId, ctx.session.id))) {
throw respond.Forbidden({
message: 'Access to project tables denied',
})
Expand Down Expand Up @@ -114,6 +120,15 @@ const projectOutput = OBJ({
updatedAt: optional(NUM('The last update date of the project')),
})

const userNameCache = new Map<string, string>()
const getUserName = async (userId: string) => {
if (userNameCache.has(userId)) return userNameCache.get(userId)
const user = await getOne<{ name: { fullName: string } }>('google/user', userId)
const name = user?.name?.fullName ?? userId
userNameCache.set(userId, name)
return name
}

const defs = {
'GET/api/health': route({
fn: () => new Response('OK'),
Expand Down Expand Up @@ -153,76 +168,78 @@ const defs = {
output: OBJ({}),
description: 'Logout the user',
}),
'GET/api/users': route({
authorize: withAdminSession,
fn: () => UsersCollection.values().toArray(),
output: ARR(UserDef, 'List of users'),
description: 'Get all users',
}),
'GET/api/teams': route({
authorize: withUserSession,
fn: () => TeamsCollection.values().toArray(),
fn: async () => {
const groups = await get<{ id: string; name: string }[]>(
'google/group',
{
q: 'select((.kind == "admin#directory#group") and (.email | endswith("@01edu.ai")) and (.directMembersCount > 1)) | { id: .id, name: .name }',
},
)

const teams = await Promise.all(
groups.map(async (g) => {
const members = await get<string[]>(
`google/group/${g.id}`,
{ q: '.id' },
)
return { ...g, members }
}),
)

return new Response(JSON.stringify(teams), {
headers: {
'Cache-Control': 'max-age=3600', // 1 hour
'Content-Type': 'application/json',
},
})
},
output: ARR(TeamDef, 'List of teams'),
description: 'Get all teams',
}),
'POST/api/teams': route({
authorize: withAdminSession,
fn: (_ctx, team) =>
TeamsCollection.insert({
teamId: team.teamId,
teamName: team.teamName,
teamMembers: [],
}),
input: OBJ({
teamId: STR('The ID of the team'),
teamName: STR('The name of the team'),
}),
output: TeamDef,
description: 'Create a new team',
}),
'GET/api/team': route({
authorize: withUserSession,
fn: (_ctx, { teamId }) => {
const team = TeamsCollection.get(teamId)
if (!team) throw respond.NotFound({ message: 'Team not found' })
return team
fn: async (_ctx, { id }) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

const userNamesCache = new Map<string, string>()
export const getUserName = async (id: string) => {
  const cachedName = userNamesCache.get(id)
  if (cachedName) return cachedName
  const user = await getOne<{ name: { fullName: string } }>(
    'google/user',
    id,
  )
  userNamesCache.set(id, user.fullName)
  return user.fullName
}

const group = await getOne<{ name: string }>('google/group', id)
if (!group) throw respond.NotFound({ message: 'Team not found' })

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

get group first: google/group/${id}, .name
get members: google/group/${id}/member { email: .email, .id: .id, name: get("google/user", .id) | .fullName }

const members = await get<{ id: string; email: string }[]>(
`google/group/${id}`,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

make sure we don't have the group itself as a result

{ q: '{ id: .id, email: .email }' },
)

const enrichedMembers = await Promise.all(
members.map(async (m) => {
const name = await getUserName(m.id)
const admin = AdminsCollection.get(m.id)
return {
email: m.email,
id: m.id,
name,
isAdmin: !!admin,
}
}),
)

return new Response(
JSON.stringify({
id,
name: group.name,
members: enrichedMembers,
}),
{
headers: {
'Cache-Control': 'max-age=3600', // 1 hour
'Content-Type': 'application/json',
},
},
)
},
input: OBJ({ teamId: STR('The ID of the team') }),
output: TeamDef,
input: OBJ({ id: STR('The ID of the team') }),
output: TeamDetailDef,
description: 'Get a team by ID',
}),
'PUT/api/team': route({
authorize: withAdminSession,
fn: (_ctx, input) =>
TeamsCollection.update(input.teamId, {
teamName: input.teamName,
teamMembers: input.teamMembers || undefined,
}),
input: OBJ({
teamId: STR('The ID of the team'),
teamName: STR('The name of the team'),
teamMembers: optional(
ARR(
STR('The user emails of team members'),
'The list of user emails who are members of the team',
),
),
}),
output: TeamDef,
description: 'Update a team by ID',
}),
'DELETE/api/team': route({
authorize: withAdminSession,
fn: (_ctx, { teamId }) => {
const team = TeamsCollection.get(teamId)
if (!team) throw respond.NotFound({ message: 'Team not found' })
TeamsCollection.delete(teamId)
return true
},
input: OBJ({ teamId: STR('The ID of the team') }),
output: BOOL('Indicates if the team was deleted'),
description: 'Delete a team by ID',
}),
'GET/api/projects': route({
authorize: withUserSession,
fn: () => ProjectsCollection.values().toArray(),
Expand Down Expand Up @@ -427,7 +444,7 @@ const defs = {
}),
'POST/api/deployment/logs': route({
authorize: withUserSession,
fn: (ctx, params) => {
fn: async (ctx, params) => {
const deployment = DeploymentsCollection.get(params.deployment)
if (!deployment) {
throw respond.NotFound({ message: 'Deployment not found' })
Expand All @@ -440,7 +457,7 @@ const defs = {
const project = ProjectsCollection.get(deployment.projectId)
if (!project) throw respond.NotFound({ message: 'Project not found' })
if (!project.isPublic && !ctx.session.isAdmin) {
if (!userInTeam(project.teamId, ctx.session.userEmail)) {
if (!(await userInTeam(project.teamId, ctx.session.email))) {
throw respond.Forbidden({ message: 'Access to project logs denied' })
}
}
Expand Down Expand Up @@ -476,8 +493,8 @@ const defs = {
}),
'POST/api/deployment/table/data': route({
authorize: withUserSession,
fn: (ctx, { deployment, table, ...input }) => {
const dep = withDeploymentTableAccess(ctx, deployment)
fn: async (ctx, { deployment, table, ...input }) => {
const dep = await withDeploymentTableAccess(ctx, deployment)

const schema = DatabaseSchemasCollection.get(deployment)
if (!schema) throw respond.NotFound({ message: 'Schema not cached yet' })
Expand Down Expand Up @@ -529,8 +546,8 @@ const defs = {
}),
'POST/api/deployment/table/update': route({
authorize: withUserSession,
fn: (ctx, { deployment, table, pk, data }) => {
const dep = withDeploymentTableAccess(ctx, deployment)
fn: async (ctx, { deployment, table, pk, data }) => {
const dep = await withDeploymentTableAccess(ctx, deployment)
return updateTableData(dep, table, pk, data)
},
input: OBJ({
Expand Down Expand Up @@ -565,7 +582,7 @@ const defs = {
const project = ProjectsCollection.get(dep.projectId)
if (!project) throw respond.NotFound({ message: 'Project not found' })
if (!project.isPublic && !ctx.session.isAdmin) {
if (!userInTeam(project.teamId, ctx.session.userEmail)) {
if (!(await userInTeam(project.teamId, ctx.session.email))) {
throw new respond.ForbiddenError({
message: 'Access to project queries denied',
})
Expand Down
Loading
Loading