-
Notifications
You must be signed in to change notification settings - Fork 0
Integrate the STORE API for automated user insertion into groups #101
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
53b81bb
fe29245
096e2e5
7559c15
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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() | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
|
@@ -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) => { | ||
|
|
@@ -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}`, | ||
| userId, | ||
| ) | ||
| return !!matches | ||
| } | ||
|
|
||
| const withDeploymentTableAccess = ( | ||
| const withDeploymentTableAccess = async ( | ||
| ctx: RequestContext & { session: User }, | ||
| deployment: string, | ||
| ) => { | ||
|
|
@@ -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', | ||
| }) | ||
|
|
@@ -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'), | ||
|
|
@@ -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 }) => { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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' }) | ||
|
|
||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. get group first: |
||
| const members = await get<{ id: string; email: string }[]>( | ||
| `google/group/${id}`, | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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(), | ||
|
|
@@ -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' }) | ||
|
|
@@ -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' }) | ||
| } | ||
| } | ||
|
|
@@ -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' }) | ||
|
|
@@ -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({ | ||
|
|
@@ -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', | ||
| }) | ||
|
|
||
There was a problem hiding this comment.
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, usinggetOne