Skip to content
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

feat(ee): create new project members #1768

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
7 changes: 4 additions & 3 deletions packages/shared/prisma/generated/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,14 +94,15 @@ export type AuditLog = {
id: string;
created_at: Generated<Timestamp>;
updated_at: Generated<Timestamp>;
user_id: string;
project_id: string;
user_project_role: MembershipRole;
resource_type: string;
resource_id: string;
action: string;
before: string | null;
after: string | null;
project_id: string;
user_id: string | null;
user_project_role: MembershipRole | null;
public_api_key: string | null;
};
export type CronJobs = {
name: string;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
-- DropForeignKey
ALTER TABLE "audit_logs" DROP CONSTRAINT "audit_logs_user_id_fkey";

-- AlterTable
ALTER TABLE "audit_logs" ADD COLUMN "public_api_key" TEXT,
ALTER COLUMN "user_id" DROP NOT NULL,
ALTER COLUMN "user_project_role" DROP NOT NULL;

-- AddForeignKey
ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
31 changes: 18 additions & 13 deletions packages/shared/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -574,19 +574,24 @@ model Model {
}

model AuditLog {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
userId String @map("user_id")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
projectId String @map("project_id")
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
userProjectRole MembershipRole @map("user_project_role")
resourceType String @map("resource_type")
resourceId String @map("resource_id")
action String
before String? //stringified JSON
after String? // stringified JSON
id String @id @default(cuid())
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @default(now()) @updatedAt @map("updated_at")
resourceType String @map("resource_type")
resourceId String @map("resource_id")
action String
before String? //stringified JSON
after String? // stringified JSON
projectId String @map("project_id")
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)

// user via frontend
userId String? @map("user_id")
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
userProjectRole MembershipRole? @map("user_project_role")

// via api key
publicApiKey String? @map("public_api_key")

@@index([projectId])
@@index([createdAt])
Expand Down
89 changes: 89 additions & 0 deletions web/src/ee/membership-api/memberships-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { z } from "zod";
import { type NextApiRequest, type NextApiResponse } from "next";
import { cors, runMiddleware } from "@/src/features/public-api/server/cors";
import { verifyAuthHeaderAndReturnScope } from "@/src/features/public-api/server/apiAuth";
import { isEeAvailable } from "@langfuse/ee";
import { isPrismaException } from "@/src/utils/exceptions";
import {
CreateMemberInput,
createNewMember,
} from "@/src/features/rbac/lib/createMember";

export async function membershipsHandler(
req: NextApiRequest,
res: NextApiResponse,
) {
await runMiddleware(req, res, cors);

// Check EE
if (!isEeAvailable) {
res.status(403).json({ error: "EE is not available" });
return;
}

// CHECK AUTH
const authCheck = await verifyAuthHeaderAndReturnScope(
req.headers.authorization,
);
if (!authCheck.validKey)
return res.status(401).json({
message: authCheck.error,
});
// END CHECK AUTH

// CHECK ACCESS SCOPE
if (authCheck.scope.accessLevel !== "all") {
return res.status(401).json({
message: "Access denied - need to use basic auth with secret key",
});
}
// END CHECK ACCESS SCOPE

try {
if (req.method === "POST") {
console.log(
"Trying to create new member, project ",
authCheck.scope.projectId,
", body:",
JSON.stringify(req.body, null, 2),
);

const body = CreateMemberInput.parse(req.body);

await createNewMember({
newMember: body,
auditLogSource: {
publicApiKey: authCheck.publicKey,
projectId: authCheck.scope.projectId,
},
});

return res.status(200).json({
message: "Member created",
});
} else {
res.status(405).json({
message: "Method Not Allowed",
});
}
} catch (error: unknown) {
console.error(error);
if (error instanceof z.ZodError) {
return res.status(400).json({
message: "Invalid request data",
error: error.errors,
});
}
if (isPrismaException(error)) {
return res.status(500).json({
error: "Internal Server Error",
});
}
const errorMessage =
error instanceof Error ? error.message : "An unknown error occurred";
res.status(500).json({
message: "Invalid request data",
error: errorMessage,
});
}
}
37 changes: 25 additions & 12 deletions web/src/features/audit-logs/auditLog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,7 @@ export type AuditableResource =
| "evalTemplate"
| "job";

type AuditLog = {
resourceType: AuditableResource;
resourceId: string;
action: string;
before?: unknown;
after?: unknown;
} & (
export type AuditLogSource =
| {
projectId: string;
userId: string;
Expand All @@ -38,17 +32,36 @@ type AuditLog = {
projectId: string;
};
}
);
| {
projectId: string;
publicApiKey: string;
};

type AuditLog = {
resourceType: AuditableResource;
resourceId: string;
action: string;
before?: unknown;
after?: unknown;
} & AuditLogSource;

export async function auditLog(log: AuditLog, prisma?: typeof _prisma) {
await (prisma ?? _prisma).auditLog.create({
data: {
projectId: "projectId" in log ? log.projectId : log.session.projectId,
userId: "userId" in log ? log.userId : log.session.user.id,
userId:
"publicApiKey" in log
? null
: "userId" in log
? log.userId
: log.session.user.id,
userProjectRole:
"userProjectRole" in log
? log.userProjectRole
: log.session.projectRole,
"publicApiKey" in log
? null
: "userProjectRole" in log
? log.userProjectRole
: log.session.projectRole,
publicApiKey: "publicApiKey" in log ? log.publicApiKey : null,
resourceType: log.resourceType,
resourceId: log.resourceId,
action: log.action,
Expand Down
8 changes: 5 additions & 3 deletions web/src/features/email/lib/project-invitation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ const authUrl =

export const sendProjectInvitation = async (
to: string,
inviterName: string,
inviterEmail: string,
inviterName: string | null,
inviterEmail: string | null,
projectName: string,
) => {
if (!env.EMAIL_FROM_ADDRESS || !env.SMTP_CONNECTION_URL) {
Expand Down Expand Up @@ -50,7 +50,9 @@ export const sendProjectInvitation = async (
address: env.EMAIL_FROM_ADDRESS,
name: "Langfuse",
},
subject: `${inviterName} invited you to join "${projectName}"`,
subject: inviterName
? `Join project "${projectName}" on Langfuse`
: `${inviterName} invited you to join "${projectName}"`,
html: htmlTemplate,
});
} catch (error) {
Expand Down
32 changes: 20 additions & 12 deletions web/src/features/email/templates/ProjectInvitation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ import {
import { env } from "@/src/env.mjs";

interface ProjectInvitationTemplateProps {
invitedByUsername: string;
invitedByUserEmail: string;
invitedByUsername: string | null;
invitedByUserEmail: string | null;
projectName: string;
recieverEmail: string;
inviteLink: string;
Expand All @@ -32,7 +32,9 @@ export const ProjectInvitationTemplate = ({
recieverEmail,
inviteLink,
}: ProjectInvitationTemplateProps) => {
const previewText = `Join ${invitedByUsername} on Langfuse`;
const previewText = invitedByUsername
? `Join ${invitedByUsername} on Langfuse`
: `Join ${projectName} on Langfuse`;

return (
<Html>
Expand All @@ -55,15 +57,21 @@ export const ProjectInvitationTemplate = ({
</Heading>
<Text className="text-sm leading-6 text-black">Hello,</Text>
<Text className="text-sm leading-6 text-black">
<strong>{invitedByUsername}</strong> (
<Link
href={`mailto:${invitedByUserEmail}`}
className="text-blue-600 no-underline"
>
{invitedByUserEmail}
</Link>
) has invited you to the <strong>{projectName}</strong> project on
Langfuse.
{invitedByUsername && invitedByUserEmail ? (
<>
<strong>{invitedByUsername}</strong> (
<Link
href={`mailto:${invitedByUserEmail}`}
className="text-blue-600 no-underline"
>
{invitedByUserEmail}
</Link>
) has invited you
</>
) : (
"You were invited"
)}{" "}
to the <strong>{projectName}</strong> project on Langfuse.
</Text>
<Section className="mb-4 mt-8 text-center">
<Button
Expand Down
3 changes: 3 additions & 0 deletions web/src/features/public-api/server/apiAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export type AuthHeaderVerificationResult =
| {
validKey: true;
scope: ApiAccessScope;
publicKey: string;
}
| {
validKey: false;
Expand Down Expand Up @@ -77,6 +78,7 @@ export async function verifyAuthHeaderAndReturnScope(
projectId: projectId,
accessLevel: "all",
},
publicKey,
};
}
// Bearer auth, limited scope, only needs public key
Expand All @@ -94,6 +96,7 @@ export async function verifyAuthHeaderAndReturnScope(
projectId: dbKey.projectId,
accessLevel: "scores",
},
publicKey,
};
}
} catch (error: unknown) {
Expand Down