Add login/register logic

Signed-off-by: gearnode <bryan@frimin.fr>
This commit is contained in:
gearnode
2025-02-25 17:32:26 +01:00
parent 49c3807b4f
commit e9ae77a4a0
28 changed files with 2075 additions and 419 deletions

View File

@@ -8,7 +8,10 @@ import { BrowserRouter, Route, Routes, useLocation } from "react-router";
import "App.css";
import ErrorBoundary from "./components/ErrorBoundary";
import ConsoleLayout from "./layouts/ConsoleLayout";
import AuthLayout from "./layouts/AuthLayout";
import { RelayEnvironment } from "./RelayEnvironment";
import { AuthProvider } from "./contexts/AuthContext";
import { ProtectedRoute } from "./components/ProtectedRoute";
posthog.init(process.env.POSTHOG_KEY!, {
api_host: process.env.POSTHOG_HOST,
@@ -29,11 +32,12 @@ const VendorOverviewPage = lazy(() => import("./pages/VendorOverviewPage"));
const SettingsPage = lazy(() => import("./pages/SettingsPage"));
const CreatePeoplePage = lazy(() => import("./pages/CreatePeoplePage"));
const FrameworkOverviewPage = lazy(
() => import("./pages/FrameworkOverviewPage"),
() => import("./pages/FrameworkOverviewPage")
);
const ControlOverviewPage = lazy(() => import("./pages/ControlOverviewPage"));
const PeopleOverviewPage = lazy(() => import("./pages/PeopleOverviewPage"));
const LoginPage = lazy(() => import("./pages/LoginPage"));
const RegisterPage = lazy(() => import("./pages/RegisterPage"));
function App() {
return (
@@ -43,125 +47,171 @@ function App() {
<RelayEnvironmentProvider environment={RelayEnvironment}>
<HelmetProvider>
<BrowserRouter>
<Routes>
<Route
path="/*"
element={
<ErrorBoundaryWithLocation>
<ConsoleLayout />
</ErrorBoundaryWithLocation>
}
>
<AuthProvider>
<Routes>
{/* Authentication Routes - Accessible without login */}
<Route
index
path="/login"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<AuthLayout />
</ErrorBoundaryWithLocation>
}
>
<Route
index
element={
<Suspense>
<ErrorBoundaryWithLocation>
<LoginPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
</Route>
<Route
path="/register"
element={
<ErrorBoundaryWithLocation>
<AuthLayout />
</ErrorBoundaryWithLocation>
}
>
<Route
index
element={
<Suspense>
<ErrorBoundaryWithLocation>
<RegisterPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
</Route>
{/* Protected Routes - Require authentication */}
<Route element={<ProtectedRoute />}>
<Route
path="/*"
element={
<ErrorBoundaryWithLocation>
<HomePage />
<ConsoleLayout />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="peoples"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<PeopleListPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="peoples/create"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<CreatePeoplePage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="peoples/:peopleId"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<PeopleOverviewPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="vendors"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<VendorListPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="frameworks"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<FrameworkListPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="frameworks/:frameworkId"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<FrameworkOverviewPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="frameworks/:frameworkId/controls/:controlId"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<ControlOverviewPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="vendors/:vendorId"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<VendorOverviewPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="settings"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<SettingsPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="*"
element={
<Suspense>
<NotFoundPage />
</Suspense>
}
/>
</Route>
</Routes>
}
>
<Route
index
element={
<Suspense>
<ErrorBoundaryWithLocation>
<HomePage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="peoples"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<PeopleListPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="peoples/create"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<CreatePeoplePage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="peoples/:peopleId"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<PeopleOverviewPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="vendors"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<VendorListPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="frameworks"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<FrameworkListPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="frameworks/:frameworkId"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<FrameworkOverviewPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="frameworks/:frameworkId/controls/:controlId"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<ControlOverviewPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="vendors/:vendorId"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<VendorOverviewPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="settings"
element={
<Suspense>
<ErrorBoundaryWithLocation>
<SettingsPage />
</ErrorBoundaryWithLocation>
</Suspense>
}
/>
<Route
path="*"
element={
<Suspense>
<NotFoundPage />
</Suspense>
}
/>
</Route>
</Route>
</Routes>
</AuthProvider>
</BrowserRouter>
</HelmetProvider>
</RelayEnvironmentProvider>

View File

@@ -0,0 +1,20 @@
import { Navigate, Outlet, useLocation } from "react-router";
import { useAuth } from "@/contexts/AuthContext";
export function ProtectedRoute() {
const { isAuthenticated, isLoading } = useAuth();
const location = useLocation();
// Show nothing while checking authentication
if (isLoading) {
return null;
}
// Redirect to login if not authenticated
if (!isAuthenticated) {
return <Navigate to="/login" replace state={{ from: location }} />;
}
// Render the protected content
return <Outlet />;
}

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<578d66fa0d0065452d9fa64611b7cfda>>
* @generated SignedSource<<717a5c6c939c65b7e0790f84e2c41bcd>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -72,7 +72,7 @@ return {
{
"alias": null,
"args": null,
"concreteType": "Viewer",
"concreteType": "User",
"kind": "LinkedField",
"name": "viewer",
"plural": false,
@@ -109,7 +109,7 @@ return {
{
"alias": null,
"args": null,
"concreteType": "Viewer",
"concreteType": "User",
"kind": "LinkedField",
"name": "viewer",
"plural": false,

View File

@@ -0,0 +1,78 @@
import React, { createContext, useContext, useEffect, useState } from "react";
import { useNavigate } from "react-router";
import { buildEndpoint } from "@/utils";
interface AuthContextType {
isAuthenticated: boolean;
isLoading: boolean;
checkAuth: () => Promise<boolean>;
logout: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState<boolean>(true);
const navigate = useNavigate();
const checkAuth = async (): Promise<boolean> => {
try {
// Make a request to an endpoint that requires authentication
const response = await fetch(buildEndpoint("/console/v1/query"), {
method: "POST",
credentials: "include",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
operationName: "CheckAuth",
query: `query CheckAuth { viewer { id } }`,
variables: {},
}),
});
const authenticated =
response.ok && !response.headers.get("WWW-Authenticate");
setIsAuthenticated(authenticated);
return authenticated;
} catch (error) {
setIsAuthenticated(false);
return false;
} finally {
setIsLoading(false);
}
};
const logout = async (): Promise<void> => {
try {
await fetch(buildEndpoint("/console/v1/auth/logout"), {
method: "POST",
credentials: "include",
});
} finally {
setIsAuthenticated(false);
navigate("/login");
}
};
useEffect(() => {
checkAuth();
}, []);
return (
<AuthContext.Provider
value={{ isAuthenticated, isLoading, checkAuth, logout }}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth(): AuthContextType {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}

View File

@@ -0,0 +1,40 @@
import { Outlet } from "react-router";
import { Toaster } from "@/components/ui/toaster";
export default function AuthLayout() {
return (
<div className="flex min-h-screen flex-col">
<div className="flex flex-1">
<div className="flex flex-1 flex-col bg-muted/40">
<div className="flex flex-1 items-center justify-center">
<main className="w-full max-w-md p-6">
<Outlet />
</main>
</div>
</div>
<div className="hidden flex-1 bg-gradient-to-br from-gray-900 to-gray-950 lg:block">
<div className="flex h-full items-center justify-center p-8">
<div className="relative h-full w-full">
<div className="absolute right-0 top-1/4 z-10">
<img
src="/assets/android-chrome-512x512.png"
alt="Probo Mascot"
className="h-auto w-96"
/>
</div>
<div className="absolute left-8 top-1/3 z-0 text-white">
<h1 className="text-4xl font-bold leading-tight">
Navigate compliance with
<br />
confidence thanks to{" "}
<span className="text-lime-400">probo</span>
</h1>
</div>
</div>
</div>
</div>
</div>
<Toaster />
</div>
);
}

View File

@@ -0,0 +1,153 @@
import { useState } from "react";
import { Link, useLocation, useNavigate } from "react-router";
import { Helmet } from "react-helmet-async";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { buildEndpoint } from "@/utils";
import { useToast } from "@/hooks/use-toast";
import { useAuth } from "@/contexts/AuthContext";
export default function LoginPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
const { toast } = useToast();
const navigate = useNavigate();
const location = useLocation();
const { checkAuth } = useAuth();
// Get the redirect path from location state or default to home
const from = location.state?.from?.pathname || "/";
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email || !password) {
toast({
title: "Error",
description: "Email and password are required",
variant: "destructive",
});
return;
}
setIsLoading(true);
try {
const response = await fetch(buildEndpoint("/console/v1/auth/login"), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || "Login failed");
}
// Check authentication status after login
await checkAuth();
// Redirect to the original page or home after successful login
navigate(from, { replace: true });
} catch (error) {
toast({
title: "Error",
description: error instanceof Error ? error.message : "Login failed",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
return (
<>
<Helmet>
<title>Log in - Probo</title>
</Helmet>
<div className="space-y-6">
<div className="space-y-2 text-center">
<h1 className="text-3xl font-bold">Log in</h1>
<p className="text-gray-500 dark:text-gray-400">
Enter your credentials to access your account
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="name@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Logging in..." : "Log in with email"}
</Button>
</form>
<div className="text-center">
<p className="text-sm text-gray-500 dark:text-gray-400">
Don't have an account?{" "}
<Link to="/register" className="underline">
Sign up here
</Link>
</p>
</div>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or continue with
</span>
</div>
</div>
<Button variant="outline" className="w-full" disabled={isLoading}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="mr-2 h-4 w-4"
>
<circle cx="12" cy="12" r="10" />
<path d="M17.13 17.13v-4.26l-3.2 3.2a4.33 4.33 0 0 1-6.13-6.13l3.2-3.2h-4.26" />
</svg>
Log in with Google
</Button>
</div>
</>
);
}

View File

@@ -0,0 +1,155 @@
import { useState } from "react";
import { Link, useNavigate } from "react-router";
import { Helmet } from "react-helmet-async";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { buildEndpoint } from "@/utils";
import { useToast } from "@/hooks/use-toast";
import { useAuth } from "@/contexts/AuthContext";
export default function RegisterPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
const { toast } = useToast();
const navigate = useNavigate();
const { checkAuth } = useAuth();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email || !password) {
toast({
title: "Error",
description: "Email and password are required",
variant: "destructive",
});
return;
}
setIsLoading(true);
try {
const response = await fetch(buildEndpoint("/console/v1/auth/register"), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify({ email, password }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || "Registration failed");
}
toast({
title: "Success",
description: "Account created successfully",
});
// Check authentication status after registration
await checkAuth();
// Redirect to home page after successful registration
navigate("/");
} catch (error) {
toast({
title: "Error",
description:
error instanceof Error ? error.message : "Registration failed",
variant: "destructive",
});
} finally {
setIsLoading(false);
}
};
return (
<>
<Helmet>
<title>Sign up - Probo</title>
</Helmet>
<div className="space-y-6">
<div className="space-y-2 text-center">
<h1 className="text-3xl font-bold">Sign up</h1>
<p className="text-gray-500 dark:text-gray-400">
Enter your information to create an account
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="name@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? "Creating account..." : "Sign up with email"}
</Button>
</form>
<div className="text-center">
<p className="text-sm text-gray-500 dark:text-gray-400">
Already have an account?{" "}
<Link to="/login" className="underline">
Log in here
</Link>
</p>
</div>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or continue with
</span>
</div>
</div>
<Button variant="outline" className="w-full" disabled={isLoading}>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="mr-2 h-4 w-4"
>
<circle cx="12" cy="12" r="10" />
<path d="M17.13 17.13v-4.26l-3.2 3.2a4.33 4.33 0 0 1-6.13-6.13l3.2-3.2h-4.26" />
</svg>
Sign up with Google
</Button>
</div>
</>
);
}

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<25866c1f4e6fb640f91b8d05651df9f4>>
* @generated SignedSource<<bcc0482212bee6fc9682545cc8255139>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -36,7 +36,7 @@ v1 = [
{
"alias": null,
"args": null,
"concreteType": "Viewer",
"concreteType": "User",
"kind": "LinkedField",
"name": "viewer",
"plural": false,

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<b79fe4cf20a9bc930e7bd90ccc2daf03>>
* @generated SignedSource<<903f19e5b3c193151107de5969fe5259>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -163,7 +163,7 @@ return {
{
"alias": null,
"args": null,
"concreteType": "Viewer",
"concreteType": "User",
"kind": "LinkedField",
"name": "viewer",
"plural": false,
@@ -197,7 +197,7 @@ return {
{
"alias": null,
"args": null,
"concreteType": "Viewer",
"concreteType": "User",
"kind": "LinkedField",
"name": "viewer",
"plural": false,

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<d57e3b87db862a1cfa086ea4b410d567>>
* @generated SignedSource<<7ca2f2186b657853d9fce4c7a21a7db1>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -615,7 +615,7 @@ return {
{
"alias": null,
"args": null,
"concreteType": "Viewer",
"concreteType": "User",
"kind": "LinkedField",
"name": "viewer",
"plural": false,
@@ -654,7 +654,7 @@ return {
{
"alias": null,
"args": null,
"concreteType": "Viewer",
"concreteType": "User",
"kind": "LinkedField",
"name": "viewer",
"plural": false,

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<dde370798e95a61988dd69cdde3acaad>>
* @generated SignedSource<<fd54290261cf29d9b3cf42b70a68f398>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -94,7 +94,7 @@ return {
{
"alias": null,
"args": null,
"concreteType": "Viewer",
"concreteType": "User",
"kind": "LinkedField",
"name": "viewer",
"plural": false,
@@ -137,7 +137,7 @@ return {
{
"alias": null,
"args": null,
"concreteType": "Viewer",
"concreteType": "User",
"kind": "LinkedField",
"name": "viewer",
"plural": false,

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<fab1b817b8d6876b5bf4dc9320c4deef>>
* @generated SignedSource<<0b5e0bcf4f6519e1ccb1d7ffa3e488cb>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -56,7 +56,7 @@ return {
{
"alias": null,
"args": null,
"concreteType": "Viewer",
"concreteType": "User",
"kind": "LinkedField",
"name": "viewer",
"plural": false,
@@ -91,7 +91,7 @@ return {
{
"alias": null,
"args": null,
"concreteType": "Viewer",
"concreteType": "User",
"kind": "LinkedField",
"name": "viewer",
"plural": false,

View File

@@ -1,5 +1,5 @@
/**
* @generated SignedSource<<4b2d9a3c1a701053fe929da59580308b>>
* @generated SignedSource<<42f5a79d59c05d56dc664163fd507fed>>
* @lightSyntaxTransform
* @nogrep
*/
@@ -94,7 +94,7 @@ return {
{
"alias": null,
"args": null,
"concreteType": "Viewer",
"concreteType": "User",
"kind": "LinkedField",
"name": "viewer",
"plural": false,
@@ -137,7 +137,7 @@ return {
{
"alias": null,
"args": null,
"concreteType": "Viewer",
"concreteType": "User",
"kind": "LinkedField",
"name": "viewer",
"plural": false,

View File

@@ -31,6 +31,7 @@ type (
AllowedOrigins []string
Probo *probo.Service
Usrmgr *usrmgr.Service
Auth console_v1.AuthConfig
}
Server struct {
@@ -39,7 +40,8 @@ type (
)
var (
ErrMissingProboService = errors.New("server configuration requires a valid probo.Service instance")
ErrMissingProboService = errors.New("server configuration requires a valid probo.Service instance")
ErrMissingUsrmgrService = errors.New("server configuration requires a valid usrmgr.Service instance")
)
func methodNotAllowed(w http.ResponseWriter, r *http.Request) {
@@ -71,6 +73,10 @@ func NewServer(cfg Config) (*Server, error) {
return nil, ErrMissingProboService
}
if cfg.Usrmgr == nil {
return nil, ErrMissingUsrmgrService
}
return &Server{
cfg: cfg,
}, nil
@@ -94,7 +100,8 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
router.Use(cors.Handler(corsOpts))
router.Mount("/console/v1", console_v1.NewMux(s.cfg.Probo))
// Mount the console API with authentication
router.Mount("/console/v1", console_v1.NewMux(s.cfg.Probo, s.cfg.Usrmgr, s.cfg.Auth))
router.ServeHTTP(w, r)
}

View File

@@ -0,0 +1,203 @@
package console_v1
import (
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/getprobo/probo/pkg/gid"
"github.com/getprobo/probo/pkg/usrmgr"
"github.com/go-chi/chi/v5"
)
type (
// RegisterRequest represents the request body for user registration
RegisterRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
// LoginRequest represents the request body for user login
LoginRequest struct {
Email string `json:"email"`
Password string `json:"password"`
}
// AuthResponse represents the response for successful authentication
AuthResponse struct {
User UserResponse `json:"user"`
Session SessionResponse `json:"session"`
}
// UserResponse represents user data in the authentication response
UserResponse struct {
ID gid.GID `json:"id"`
Email string `json:"email"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
// SessionResponse represents session data in the authentication response
SessionResponse struct {
ID gid.GID `json:"id"`
ExpiresAt time.Time `json:"expiresAt"`
}
)
// RegisterHandler handles user registration
func RegisterHandler(usrmgrSvc *usrmgr.Service, authCfg AuthConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Parse request body
var req RegisterRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Validate request
if req.Email == "" || req.Password == "" {
http.Error(w, "Email and password are required", http.StatusBadRequest)
return
}
// Register the user
user, err := usrmgrSvc.RegisterUser(
r.Context(),
usrmgr.RegisterUserParams{
Email: req.Email,
Password: req.Password,
},
)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to register user: %v", err), http.StatusInternalServerError)
return
}
// Log the user in
session, err := usrmgrSvc.Login(r.Context(), req.Email, req.Password)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to login after registration: %v", err), http.StatusInternalServerError)
return
}
// Set the session cookie
setSessionCookie(w, session.ID.String(), authCfg)
// Return response
resp := AuthResponse{
User: UserResponse{
ID: user.ID,
Email: user.EmailAddress,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
},
Session: SessionResponse{
ID: session.ID,
ExpiresAt: session.ExpiredAt,
},
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
}
}
// LoginHandler handles user login
func LoginHandler(usrmgrSvc *usrmgr.Service, authCfg AuthConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Parse request body
var req LoginRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Invalid request body", http.StatusBadRequest)
return
}
// Validate request
if req.Email == "" || req.Password == "" {
http.Error(w, "Email and password are required", http.StatusBadRequest)
return
}
// Login the user
session, err := usrmgrSvc.Login(r.Context(), req.Email, req.Password)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to login: %v", err), http.StatusUnauthorized)
return
}
// Get the user
user, err := usrmgrSvc.GetUserBySession(r.Context(), session.ID)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to get user: %v", err), http.StatusInternalServerError)
return
}
// Set the session cookie
setSessionCookie(w, session.ID.String(), authCfg)
// Return response
resp := AuthResponse{
User: UserResponse{
ID: user.ID,
Email: user.EmailAddress,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
},
Session: SessionResponse{
ID: session.ID,
ExpiresAt: session.ExpiredAt,
},
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
http.Error(w, "Failed to encode response", http.StatusInternalServerError)
return
}
}
}
// LogoutHandler handles user logout
func LogoutHandler(usrmgrSvc *usrmgr.Service, authCfg AuthConfig) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// Get session from cookie
cookie, err := r.Cookie(authCfg.CookieName)
if err != nil || cookie.Value == "" {
http.Error(w, "No active session", http.StatusBadRequest)
return
}
// Parse the session ID
sessionID, err := gid.ParseGID(cookie.Value)
if err != nil {
http.Error(w, "Invalid session ID", http.StatusBadRequest)
return
}
// Logout the user
err = usrmgrSvc.Logout(r.Context(), sessionID)
if err != nil {
http.Error(w, fmt.Sprintf("Failed to logout: %v", err), http.StatusInternalServerError)
return
}
// Clear the session cookie
clearSessionCookie(w, authCfg)
// Return success response
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]bool{"success": true})
}
}
// RegisterAuthRoutes registers the authentication routes
func RegisterAuthRoutes(r chi.Router, usrmgrSvc *usrmgr.Service, authCfg AuthConfig) {
r.Post("/auth/register", RegisterHandler(usrmgrSvc, authCfg))
r.Post("/auth/login", LoginHandler(usrmgrSvc, authCfg))
r.Post("/auth/logout", LogoutHandler(usrmgrSvc, authCfg))
}

View File

@@ -0,0 +1,35 @@
package console_v1
import (
"net/http"
)
// setSessionCookie sets a session cookie in the response
func setSessionCookie(w http.ResponseWriter, sessionID string, cfg AuthConfig) {
cookie := &http.Cookie{
Name: cfg.CookieName,
Value: sessionID,
Path: cfg.CookiePath,
Domain: cfg.CookieDomain,
Secure: cfg.CookieSecure,
HttpOnly: cfg.CookieHTTPOnly,
MaxAge: int(cfg.SessionDuration.Seconds()),
SameSite: http.SameSiteLaxMode,
}
http.SetCookie(w, cookie)
}
// clearSessionCookie clears the session cookie
func clearSessionCookie(w http.ResponseWriter, cfg AuthConfig) {
cookie := &http.Cookie{
Name: cfg.CookieName,
Value: "",
Path: cfg.CookiePath,
Domain: cfg.CookieDomain,
Secure: cfg.CookieSecure,
HttpOnly: cfg.CookieHTTPOnly,
MaxAge: -1,
SameSite: http.SameSiteLaxMode,
}
http.SetCookie(w, cookie)
}

View File

@@ -17,36 +17,86 @@
package console_v1
import (
"context"
"net/http"
"time"
"github.com/99designs/gqlgen/graphql"
"github.com/99designs/gqlgen/graphql/handler"
"github.com/99designs/gqlgen/graphql/handler/extension"
"github.com/99designs/gqlgen/graphql/handler/transport"
"github.com/99designs/gqlgen/graphql/playground"
"github.com/getprobo/probo/pkg/api/console/v1/schema"
"github.com/getprobo/probo/pkg/gid"
"github.com/getprobo/probo/pkg/probo"
"github.com/getprobo/probo/pkg/usrmgr"
"github.com/getprobo/probo/pkg/usrmgr/coredata"
"github.com/go-chi/chi/v5"
"github.com/vektah/gqlparser/v2/gqlerror"
)
type (
AuthConfig struct {
CookieName string
CookieSecure bool
CookieHTTPOnly bool
CookieDomain string
CookiePath string
SessionDuration time.Duration
}
Resolver struct {
svc *probo.Service
proboSvc *probo.Service
usrmgrSvc *usrmgr.Service
authCfg AuthConfig
}
contextKey string
httpContext struct {
ResponseWriter http.ResponseWriter
Request *http.Request
}
)
func NewMux(probo *probo.Service) *chi.Mux {
const (
sessionContextKey contextKey = "session"
userContextKey contextKey = "user"
httpContextKey contextKey = "http"
)
// SessionFromContext retrieves the session from the context
func SessionFromContext(ctx context.Context) *coredata.Session {
session, _ := ctx.Value(sessionContextKey).(*coredata.Session)
return session
}
// UserFromContext retrieves the user from the context
func UserFromContext(ctx context.Context) *coredata.User {
user, _ := ctx.Value(userContextKey).(*coredata.User)
return user
}
func NewMux(proboSvc *probo.Service, usrmgrSvc *usrmgr.Service, authCfg AuthConfig) *chi.Mux {
r := chi.NewMux()
// Register authentication routes
RegisterAuthRoutes(r, usrmgrSvc, authCfg)
// GraphQL playground and query endpoint
r.Get("/", playground.Handler("GraphQL", "/console/v1/query"))
r.Post("/query", graphql(probo))
r.Post("/query", graphqlHandler(proboSvc, usrmgrSvc, authCfg))
return r
}
func graphql(probo *probo.Service) http.HandlerFunc {
func graphqlHandler(proboSvc *probo.Service, usrmgrSvc *usrmgr.Service, authCfg AuthConfig) http.HandlerFunc {
es := schema.NewExecutableSchema(
schema.Config{
Resolvers: &Resolver{
svc: probo,
proboSvc: proboSvc,
usrmgrSvc: usrmgrSvc,
authCfg: authCfg,
},
},
)
@@ -54,7 +104,60 @@ func graphql(probo *probo.Service) http.HandlerFunc {
srv.AddTransport(transport.POST{})
srv.Use(extension.Introspection{})
// Add operation middleware for authentication
srv.AroundOperations(func(ctx context.Context, next graphql.OperationHandler) graphql.ResponseHandler {
// Skip authentication for introspection queries
if op := graphql.GetOperationContext(ctx); op.OperationName == "IntrospectionQuery" {
return next(ctx)
}
// Get the user from context
user := UserFromContext(ctx)
if user == nil {
return func(ctx context.Context) *graphql.Response {
return &graphql.Response{
Errors: gqlerror.List{gqlerror.Errorf("authentication required")},
}
}
}
// Continue with the operation
return next(ctx)
})
return func(w http.ResponseWriter, r *http.Request) {
// Create HTTP context
httpCtx := &httpContext{
ResponseWriter: w,
Request: r,
}
ctx := context.WithValue(r.Context(), httpContextKey, httpCtx)
// Extract session from cookie
cookie, err := r.Cookie(authCfg.CookieName)
if err == nil && cookie.Value != "" {
// Parse the session ID
sessionID, err := gid.ParseGID(cookie.Value)
if err == nil {
// Get the session
session, err := usrmgrSvc.GetSession(r.Context(), sessionID)
if err == nil {
// Add session to context
ctx = context.WithValue(ctx, sessionContextKey, session)
// Get the user
user, err := usrmgrSvc.GetUserBySession(r.Context(), sessionID)
if err == nil {
// Add user to context
ctx = context.WithValue(ctx, userContextKey, user)
}
}
}
}
// Update the request with the new context
r = r.WithContext(ctx)
srv.ServeHTTP(w, r)
}
}

View File

@@ -342,14 +342,25 @@ type EvidenceStateTransition {
updatedAt: Datetime!
}
type Query {
node(id: ID!): Node!
viewer: Viewer!
# Authentication types
type User {
id: ID!
email: String!
organization: Organization! @goField(forceResolver: true)
createdAt: Datetime!
updatedAt: Datetime!
}
type Viewer {
type Session {
id: ID!
organization: Organization! @goField(forceResolver: true)
expiresAt: Datetime!
}
type Query {
node(id: ID!): Node!
viewer: User!
}
type Mutation {

View File

@@ -49,7 +49,7 @@ type ResolverRoot interface {
Organization() OrganizationResolver
Query() QueryResolver
Task() TaskResolver
Viewer() ViewerResolver
User() UserResolver
}
type DirectiveRoot struct {
@@ -225,6 +225,11 @@ type ComplexityRoot struct {
Viewer func(childComplexity int) int
}
Session struct {
ExpiresAt func(childComplexity int) int
ID func(childComplexity int) int
}
Task struct {
CreatedAt func(childComplexity int) int
Description func(childComplexity int) int
@@ -265,6 +270,14 @@ type ComplexityRoot struct {
Node func(childComplexity int) int
}
User struct {
CreatedAt func(childComplexity int) int
Email func(childComplexity int) int
ID func(childComplexity int) int
Organization func(childComplexity int) int
UpdatedAt func(childComplexity int) int
}
Vendor struct {
CreatedAt func(childComplexity int) int
Description func(childComplexity int) int
@@ -290,11 +303,6 @@ type ComplexityRoot struct {
Cursor func(childComplexity int) int
Node func(childComplexity int) int
}
Viewer struct {
ID func(childComplexity int) int
Organization func(childComplexity int) int
}
}
type ControlResolver interface {
@@ -322,14 +330,14 @@ type OrganizationResolver interface {
}
type QueryResolver interface {
Node(ctx context.Context, id gid.GID) (types.Node, error)
Viewer(ctx context.Context) (*types.Viewer, error)
Viewer(ctx context.Context) (*types.User, error)
}
type TaskResolver interface {
StateTransisions(ctx context.Context, obj *types.Task, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.TaskStateTransitionConnection, error)
Evidences(ctx context.Context, obj *types.Task, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.EvidenceConnection, error)
}
type ViewerResolver interface {
Organization(ctx context.Context, obj *types.Viewer) (*types.Organization, error)
type UserResolver interface {
Organization(ctx context.Context, obj *types.User) (*types.Organization, error)
}
type executableSchema struct {
@@ -1058,6 +1066,20 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.Query.Viewer(childComplexity), true
case "Session.expiresAt":
if e.complexity.Session.ExpiresAt == nil {
break
}
return e.complexity.Session.ExpiresAt(childComplexity), true
case "Session.id":
if e.complexity.Session.ID == nil {
break
}
return e.complexity.Session.ID(childComplexity), true
case "Task.createdAt":
if e.complexity.Task.CreatedAt == nil {
break
@@ -1222,6 +1244,41 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.TaskStateTransitionEdge.Node(childComplexity), true
case "User.createdAt":
if e.complexity.User.CreatedAt == nil {
break
}
return e.complexity.User.CreatedAt(childComplexity), true
case "User.email":
if e.complexity.User.Email == nil {
break
}
return e.complexity.User.Email(childComplexity), true
case "User.id":
if e.complexity.User.ID == nil {
break
}
return e.complexity.User.ID(childComplexity), true
case "User.organization":
if e.complexity.User.Organization == nil {
break
}
return e.complexity.User.Organization(childComplexity), true
case "User.updatedAt":
if e.complexity.User.UpdatedAt == nil {
break
}
return e.complexity.User.UpdatedAt(childComplexity), true
case "Vendor.createdAt":
if e.complexity.Vendor.CreatedAt == nil {
break
@@ -1341,20 +1398,6 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
return e.complexity.VendorEdge.Node(childComplexity), true
case "Viewer.id":
if e.complexity.Viewer.ID == nil {
break
}
return e.complexity.Viewer.ID(childComplexity), true
case "Viewer.organization":
if e.complexity.Viewer.Organization == nil {
break
}
return e.complexity.Viewer.Organization(childComplexity), true
}
return 0, false
}
@@ -1810,14 +1853,25 @@ type EvidenceStateTransition {
updatedAt: Datetime!
}
type Query {
node(id: ID!): Node!
viewer: Viewer!
# Authentication types
type User {
id: ID!
email: String!
organization: Organization! @goField(forceResolver: true)
createdAt: Datetime!
updatedAt: Datetime!
}
type Viewer {
type Session {
id: ID!
organization: Organization! @goField(forceResolver: true)
expiresAt: Datetime!
}
type Query {
node(id: ID!): Node!
viewer: User!
}
type Mutation {
@@ -6671,9 +6725,9 @@ func (ec *executionContext) _Query_viewer(ctx context.Context, field graphql.Col
}
return graphql.Null
}
res := resTmp.(*types.Viewer)
res := resTmp.(*types.User)
fc.Result = res
return ec.marshalNViewer2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋapiᚋconsoleᚋv1ᚋtypesᚐViewer(ctx, field.Selections, res)
return ec.marshalNUser2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋapiᚋconsoleᚋv1ᚋtypesᚐUser(ctx, field.Selections, res)
}
func (ec *executionContext) fieldContext_Query_viewer(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
@@ -6685,11 +6739,17 @@ func (ec *executionContext) fieldContext_Query_viewer(_ context.Context, field g
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
switch field.Name {
case "id":
return ec.fieldContext_Viewer_id(ctx, field)
return ec.fieldContext_User_id(ctx, field)
case "email":
return ec.fieldContext_User_email(ctx, field)
case "organization":
return ec.fieldContext_Viewer_organization(ctx, field)
return ec.fieldContext_User_organization(ctx, field)
case "createdAt":
return ec.fieldContext_User_createdAt(ctx, field)
case "updatedAt":
return ec.fieldContext_User_updatedAt(ctx, field)
}
return nil, fmt.Errorf("no field named %q was found under type Viewer", field.Name)
return nil, fmt.Errorf("no field named %q was found under type User", field.Name)
},
}
return fc, nil
@@ -6806,6 +6866,82 @@ func (ec *executionContext) fieldContext_Query___schema(_ context.Context, field
return fc, nil
}
func (ec *executionContext) _Session_id(ctx context.Context, field graphql.CollectedField, obj *types.Session) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_Session_id(ctx, field)
if err != nil {
return graphql.Null
}
ctx = graphql.WithFieldContext(ctx, fc)
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
ctx = rctx // use context from middleware stack in children
return obj.ID, nil
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null
}
res := resTmp.(gid.GID)
fc.Result = res
return ec.marshalNID2githubᚗcomᚋgetproboᚋproboᚋpkgᚋgidᚐGID(ctx, field.Selections, res)
}
func (ec *executionContext) fieldContext_Session_id(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "Session",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
return nil, errors.New("field of type ID does not have child fields")
},
}
return fc, nil
}
func (ec *executionContext) _Session_expiresAt(ctx context.Context, field graphql.CollectedField, obj *types.Session) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_Session_expiresAt(ctx, field)
if err != nil {
return graphql.Null
}
ctx = graphql.WithFieldContext(ctx, fc)
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
ctx = rctx // use context from middleware stack in children
return obj.ExpiresAt, nil
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null
}
res := resTmp.(time.Time)
fc.Result = res
return ec.marshalNDatetime2timeᚐTime(ctx, field.Selections, res)
}
func (ec *executionContext) fieldContext_Session_expiresAt(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "Session",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
return nil, errors.New("field of type Datetime does not have child fields")
},
}
return fc, nil
}
func (ec *executionContext) _Task_id(ctx context.Context, field graphql.CollectedField, obj *types.Task) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_Task_id(ctx, field)
if err != nil {
@@ -7722,6 +7858,214 @@ func (ec *executionContext) fieldContext_TaskStateTransitionEdge_node(_ context.
return fc, nil
}
func (ec *executionContext) _User_id(ctx context.Context, field graphql.CollectedField, obj *types.User) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_User_id(ctx, field)
if err != nil {
return graphql.Null
}
ctx = graphql.WithFieldContext(ctx, fc)
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
ctx = rctx // use context from middleware stack in children
return obj.ID, nil
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null
}
res := resTmp.(gid.GID)
fc.Result = res
return ec.marshalNID2githubᚗcomᚋgetproboᚋproboᚋpkgᚋgidᚐGID(ctx, field.Selections, res)
}
func (ec *executionContext) fieldContext_User_id(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "User",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
return nil, errors.New("field of type ID does not have child fields")
},
}
return fc, nil
}
func (ec *executionContext) _User_email(ctx context.Context, field graphql.CollectedField, obj *types.User) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_User_email(ctx, field)
if err != nil {
return graphql.Null
}
ctx = graphql.WithFieldContext(ctx, fc)
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
ctx = rctx // use context from middleware stack in children
return obj.Email, nil
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null
}
res := resTmp.(string)
fc.Result = res
return ec.marshalNString2string(ctx, field.Selections, res)
}
func (ec *executionContext) fieldContext_User_email(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "User",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
return nil, errors.New("field of type String does not have child fields")
},
}
return fc, nil
}
func (ec *executionContext) _User_organization(ctx context.Context, field graphql.CollectedField, obj *types.User) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_User_organization(ctx, field)
if err != nil {
return graphql.Null
}
ctx = graphql.WithFieldContext(ctx, fc)
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
ctx = rctx // use context from middleware stack in children
return ec.resolvers.User().Organization(rctx, obj)
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null
}
res := resTmp.(*types.Organization)
fc.Result = res
return ec.marshalNOrganization2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋapiᚋconsoleᚋv1ᚋtypesᚐOrganization(ctx, field.Selections, res)
}
func (ec *executionContext) fieldContext_User_organization(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "User",
Field: field,
IsMethod: true,
IsResolver: true,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
switch field.Name {
case "id":
return ec.fieldContext_Organization_id(ctx, field)
case "name":
return ec.fieldContext_Organization_name(ctx, field)
case "logoUrl":
return ec.fieldContext_Organization_logoUrl(ctx, field)
case "frameworks":
return ec.fieldContext_Organization_frameworks(ctx, field)
case "vendors":
return ec.fieldContext_Organization_vendors(ctx, field)
case "peoples":
return ec.fieldContext_Organization_peoples(ctx, field)
case "createdAt":
return ec.fieldContext_Organization_createdAt(ctx, field)
case "updatedAt":
return ec.fieldContext_Organization_updatedAt(ctx, field)
}
return nil, fmt.Errorf("no field named %q was found under type Organization", field.Name)
},
}
return fc, nil
}
func (ec *executionContext) _User_createdAt(ctx context.Context, field graphql.CollectedField, obj *types.User) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_User_createdAt(ctx, field)
if err != nil {
return graphql.Null
}
ctx = graphql.WithFieldContext(ctx, fc)
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
ctx = rctx // use context from middleware stack in children
return obj.CreatedAt, nil
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null
}
res := resTmp.(time.Time)
fc.Result = res
return ec.marshalNDatetime2timeᚐTime(ctx, field.Selections, res)
}
func (ec *executionContext) fieldContext_User_createdAt(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "User",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
return nil, errors.New("field of type Datetime does not have child fields")
},
}
return fc, nil
}
func (ec *executionContext) _User_updatedAt(ctx context.Context, field graphql.CollectedField, obj *types.User) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_User_updatedAt(ctx, field)
if err != nil {
return graphql.Null
}
ctx = graphql.WithFieldContext(ctx, fc)
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
ctx = rctx // use context from middleware stack in children
return obj.UpdatedAt, nil
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null
}
res := resTmp.(time.Time)
fc.Result = res
return ec.marshalNDatetime2timeᚐTime(ctx, field.Selections, res)
}
func (ec *executionContext) fieldContext_User_updatedAt(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "User",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
return nil, errors.New("field of type Datetime does not have child fields")
},
}
return fc, nil
}
func (ec *executionContext) _Vendor_id(ctx context.Context, field graphql.CollectedField, obj *types.Vendor) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_Vendor_id(ctx, field)
if err != nil {
@@ -8400,100 +8744,6 @@ func (ec *executionContext) fieldContext_VendorEdge_node(_ context.Context, fiel
return fc, nil
}
func (ec *executionContext) _Viewer_id(ctx context.Context, field graphql.CollectedField, obj *types.Viewer) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_Viewer_id(ctx, field)
if err != nil {
return graphql.Null
}
ctx = graphql.WithFieldContext(ctx, fc)
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
ctx = rctx // use context from middleware stack in children
return obj.ID, nil
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null
}
res := resTmp.(gid.GID)
fc.Result = res
return ec.marshalNID2githubᚗcomᚋgetproboᚋproboᚋpkgᚋgidᚐGID(ctx, field.Selections, res)
}
func (ec *executionContext) fieldContext_Viewer_id(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "Viewer",
Field: field,
IsMethod: false,
IsResolver: false,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
return nil, errors.New("field of type ID does not have child fields")
},
}
return fc, nil
}
func (ec *executionContext) _Viewer_organization(ctx context.Context, field graphql.CollectedField, obj *types.Viewer) (ret graphql.Marshaler) {
fc, err := ec.fieldContext_Viewer_organization(ctx, field)
if err != nil {
return graphql.Null
}
ctx = graphql.WithFieldContext(ctx, fc)
resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (any, error) {
ctx = rctx // use context from middleware stack in children
return ec.resolvers.Viewer().Organization(rctx, obj)
})
if err != nil {
ec.Error(ctx, err)
return graphql.Null
}
if resTmp == nil {
if !graphql.HasFieldError(ctx, fc) {
ec.Errorf(ctx, "must not be null")
}
return graphql.Null
}
res := resTmp.(*types.Organization)
fc.Result = res
return ec.marshalNOrganization2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋapiᚋconsoleᚋv1ᚋtypesᚐOrganization(ctx, field.Selections, res)
}
func (ec *executionContext) fieldContext_Viewer_organization(_ context.Context, field graphql.CollectedField) (fc *graphql.FieldContext, err error) {
fc = &graphql.FieldContext{
Object: "Viewer",
Field: field,
IsMethod: true,
IsResolver: true,
Child: func(ctx context.Context, field graphql.CollectedField) (*graphql.FieldContext, error) {
switch field.Name {
case "id":
return ec.fieldContext_Organization_id(ctx, field)
case "name":
return ec.fieldContext_Organization_name(ctx, field)
case "logoUrl":
return ec.fieldContext_Organization_logoUrl(ctx, field)
case "frameworks":
return ec.fieldContext_Organization_frameworks(ctx, field)
case "vendors":
return ec.fieldContext_Organization_vendors(ctx, field)
case "peoples":
return ec.fieldContext_Organization_peoples(ctx, field)
case "createdAt":
return ec.fieldContext_Organization_createdAt(ctx, field)
case "updatedAt":
return ec.fieldContext_Organization_updatedAt(ctx, field)
}
return nil, fmt.Errorf("no field named %q was found under type Organization", field.Name)
},
}
return fc, nil
}
func (ec *executionContext) ___Directive_name(ctx context.Context, field graphql.CollectedField, obj *introspection.Directive) (ret graphql.Marshaler) {
fc, err := ec.fieldContext___Directive_name(ctx, field)
if err != nil {
@@ -12032,6 +12282,50 @@ func (ec *executionContext) _Query(ctx context.Context, sel ast.SelectionSet) gr
return out
}
var sessionImplementors = []string{"Session"}
func (ec *executionContext) _Session(ctx context.Context, sel ast.SelectionSet, obj *types.Session) graphql.Marshaler {
fields := graphql.CollectFields(ec.OperationContext, sel, sessionImplementors)
out := graphql.NewFieldSet(fields)
deferred := make(map[string]*graphql.FieldSet)
for i, field := range fields {
switch field.Name {
case "__typename":
out.Values[i] = graphql.MarshalString("Session")
case "id":
out.Values[i] = ec._Session_id(ctx, field, obj)
if out.Values[i] == graphql.Null {
out.Invalids++
}
case "expiresAt":
out.Values[i] = ec._Session_expiresAt(ctx, field, obj)
if out.Values[i] == graphql.Null {
out.Invalids++
}
default:
panic("unknown field " + strconv.Quote(field.Name))
}
}
out.Dispatch(ctx)
if out.Invalids > 0 {
return graphql.Null
}
atomic.AddInt32(&ec.deferred, int32(len(deferred)))
for label, dfs := range deferred {
ec.processDeferredGroup(graphql.DeferredGroup{
Label: label,
Path: graphql.GetPath(ctx),
FieldSet: dfs,
Context: ctx,
})
}
return out
}
var taskImplementors = []string{"Task", "Node"}
func (ec *executionContext) _Task(ctx context.Context, sel ast.SelectionSet, obj *types.Task) graphql.Marshaler {
@@ -12392,6 +12686,91 @@ func (ec *executionContext) _TaskStateTransitionEdge(ctx context.Context, sel as
return out
}
var userImplementors = []string{"User"}
func (ec *executionContext) _User(ctx context.Context, sel ast.SelectionSet, obj *types.User) graphql.Marshaler {
fields := graphql.CollectFields(ec.OperationContext, sel, userImplementors)
out := graphql.NewFieldSet(fields)
deferred := make(map[string]*graphql.FieldSet)
for i, field := range fields {
switch field.Name {
case "__typename":
out.Values[i] = graphql.MarshalString("User")
case "id":
out.Values[i] = ec._User_id(ctx, field, obj)
if out.Values[i] == graphql.Null {
atomic.AddUint32(&out.Invalids, 1)
}
case "email":
out.Values[i] = ec._User_email(ctx, field, obj)
if out.Values[i] == graphql.Null {
atomic.AddUint32(&out.Invalids, 1)
}
case "organization":
field := field
innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) {
res = ec._User_organization(ctx, field, obj)
if res == graphql.Null {
atomic.AddUint32(&fs.Invalids, 1)
}
return res
}
if field.Deferrable != nil {
dfs, ok := deferred[field.Deferrable.Label]
di := 0
if ok {
dfs.AddField(field)
di = len(dfs.Values) - 1
} else {
dfs = graphql.NewFieldSet([]graphql.CollectedField{field})
deferred[field.Deferrable.Label] = dfs
}
dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler {
return innerFunc(ctx, dfs)
})
// don't run the out.Concurrently() call below
out.Values[i] = graphql.Null
continue
}
out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) })
case "createdAt":
out.Values[i] = ec._User_createdAt(ctx, field, obj)
if out.Values[i] == graphql.Null {
atomic.AddUint32(&out.Invalids, 1)
}
case "updatedAt":
out.Values[i] = ec._User_updatedAt(ctx, field, obj)
if out.Values[i] == graphql.Null {
atomic.AddUint32(&out.Invalids, 1)
}
default:
panic("unknown field " + strconv.Quote(field.Name))
}
}
out.Dispatch(ctx)
if out.Invalids > 0 {
return graphql.Null
}
atomic.AddInt32(&ec.deferred, int32(len(deferred)))
for label, dfs := range deferred {
ec.processDeferredGroup(graphql.DeferredGroup{
Label: label,
Path: graphql.GetPath(ctx),
FieldSet: dfs,
Context: ctx,
})
}
return out
}
var vendorImplementors = []string{"Vendor", "Node"}
func (ec *executionContext) _Vendor(ctx context.Context, sel ast.SelectionSet, obj *types.Vendor) graphql.Marshaler {
@@ -12567,76 +12946,6 @@ func (ec *executionContext) _VendorEdge(ctx context.Context, sel ast.SelectionSe
return out
}
var viewerImplementors = []string{"Viewer"}
func (ec *executionContext) _Viewer(ctx context.Context, sel ast.SelectionSet, obj *types.Viewer) graphql.Marshaler {
fields := graphql.CollectFields(ec.OperationContext, sel, viewerImplementors)
out := graphql.NewFieldSet(fields)
deferred := make(map[string]*graphql.FieldSet)
for i, field := range fields {
switch field.Name {
case "__typename":
out.Values[i] = graphql.MarshalString("Viewer")
case "id":
out.Values[i] = ec._Viewer_id(ctx, field, obj)
if out.Values[i] == graphql.Null {
atomic.AddUint32(&out.Invalids, 1)
}
case "organization":
field := field
innerFunc := func(ctx context.Context, fs *graphql.FieldSet) (res graphql.Marshaler) {
res = ec._Viewer_organization(ctx, field, obj)
if res == graphql.Null {
atomic.AddUint32(&fs.Invalids, 1)
}
return res
}
if field.Deferrable != nil {
dfs, ok := deferred[field.Deferrable.Label]
di := 0
if ok {
dfs.AddField(field)
di = len(dfs.Values) - 1
} else {
dfs = graphql.NewFieldSet([]graphql.CollectedField{field})
deferred[field.Deferrable.Label] = dfs
}
dfs.Concurrently(di, func(ctx context.Context) graphql.Marshaler {
return innerFunc(ctx, dfs)
})
// don't run the out.Concurrently() call below
out.Values[i] = graphql.Null
continue
}
out.Concurrently(i, func(ctx context.Context) graphql.Marshaler { return innerFunc(ctx, out) })
default:
panic("unknown field " + strconv.Quote(field.Name))
}
}
out.Dispatch(ctx)
if out.Invalids > 0 {
return graphql.Null
}
atomic.AddInt32(&ec.deferred, int32(len(deferred)))
for label, dfs := range deferred {
ec.processDeferredGroup(graphql.DeferredGroup{
Label: label,
Path: graphql.GetPath(ctx),
FieldSet: dfs,
Context: ctx,
})
}
return out
}
var __DirectiveImplementors = []string{"__Directive"}
func (ec *executionContext) ___Directive(ctx context.Context, sel ast.SelectionSet, obj *introspection.Directive) graphql.Marshaler {
@@ -13957,6 +14266,20 @@ func (ec *executionContext) unmarshalNUpdateVendorInput2githubᚗcomᚋgetprobo
return res, graphql.ErrorOnPath(ctx, err)
}
func (ec *executionContext) marshalNUser2githubᚗcomᚋgetproboᚋproboᚋpkgᚋapiᚋconsoleᚋv1ᚋtypesᚐUser(ctx context.Context, sel ast.SelectionSet, v types.User) graphql.Marshaler {
return ec._User(ctx, sel, &v)
}
func (ec *executionContext) marshalNUser2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋapiᚋconsoleᚋv1ᚋtypesᚐUser(ctx context.Context, sel ast.SelectionSet, v *types.User) graphql.Marshaler {
if v == nil {
if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
ec.Errorf(ctx, "the requested element is null which the schema does not allow")
}
return graphql.Null
}
return ec._User(ctx, sel, v)
}
func (ec *executionContext) marshalNVendor2githubᚗcomᚋgetproboᚋproboᚋpkgᚋapiᚋconsoleᚋv1ᚋtypesᚐVendor(ctx context.Context, sel ast.SelectionSet, v types.Vendor) graphql.Marshaler {
return ec._Vendor(ctx, sel, &v)
}
@@ -14033,20 +14356,6 @@ func (ec *executionContext) marshalNVendorEdge2ᚖgithubᚗcomᚋgetproboᚋprob
return ec._VendorEdge(ctx, sel, v)
}
func (ec *executionContext) marshalNViewer2githubᚗcomᚋgetproboᚋproboᚋpkgᚋapiᚋconsoleᚋv1ᚋtypesᚐViewer(ctx context.Context, sel ast.SelectionSet, v types.Viewer) graphql.Marshaler {
return ec._Viewer(ctx, sel, &v)
}
func (ec *executionContext) marshalNViewer2ᚖgithubᚗcomᚋgetproboᚋproboᚋpkgᚋapiᚋconsoleᚋv1ᚋtypesᚐViewer(ctx context.Context, sel ast.SelectionSet, v *types.Viewer) graphql.Marshaler {
if v == nil {
if !graphql.HasFieldError(ctx, graphql.GetFieldContext(ctx)) {
ec.Errorf(ctx, "the requested element is null which the schema does not allow")
}
return graphql.Null
}
return ec._Viewer(ctx, sel, v)
}
func (ec *executionContext) marshalN__Directive2githubᚗcomᚋ99designsᚋgqlgenᚋgraphqlᚋintrospectionᚐDirective(ctx context.Context, sel ast.SelectionSet, v introspection.Directive) graphql.Marshaler {
return ec.___Directive(ctx, sel, &v)
}

View File

@@ -220,6 +220,11 @@ type PeopleEdge struct {
type Query struct {
}
type Session struct {
ID gid.GID `json:"id"`
ExpiresAt time.Time `json:"expiresAt"`
}
type Task struct {
ID gid.GID `json:"id"`
Name string `json:"name"`
@@ -286,6 +291,14 @@ type UpdateVendorInput struct {
PrivacyPolicyURL *string `json:"privacyPolicyUrl,omitempty"`
}
type User struct {
ID gid.GID `json:"id"`
Email string `json:"email"`
Organization *Organization `json:"organization"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
}
type Vendor struct {
ID gid.GID `json:"id"`
Name string `json:"name"`
@@ -314,8 +327,3 @@ type VendorEdge struct {
Cursor page.CursorKey `json:"cursor"`
Node *Vendor `json:"node"`
}
type Viewer struct {
ID gid.GID `json:"id"`
Organization *Organization `json:"organization"`
}

View File

@@ -21,7 +21,7 @@ import (
func (r *controlResolver) StateTransisions(ctx context.Context, obj *types.Control, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.ControlStateTransitionConnection, error) {
cursor := types.NewCursor(first, after, last, before)
page, err := r.svc.ListControlStateTransitions(ctx, obj.ID, cursor)
page, err := r.proboSvc.ListControlStateTransitions(ctx, obj.ID, cursor)
if err != nil {
return nil, fmt.Errorf("cannot list control tasks: %w", err)
}
@@ -33,7 +33,7 @@ func (r *controlResolver) StateTransisions(ctx context.Context, obj *types.Contr
func (r *controlResolver) Tasks(ctx context.Context, obj *types.Control, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.TaskConnection, error) {
cursor := types.NewCursor(first, after, last, before)
page, err := r.svc.ListControlTasks(ctx, obj.ID, cursor)
page, err := r.proboSvc.ListControlTasks(ctx, obj.ID, cursor)
if err != nil {
return nil, fmt.Errorf("cannot list control tasks: %w", err)
}
@@ -45,7 +45,7 @@ func (r *controlResolver) Tasks(ctx context.Context, obj *types.Control, first *
func (r *evidenceResolver) StateTransisions(ctx context.Context, obj *types.Evidence, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.EvidenceStateTransitionConnection, error) {
cursor := types.NewCursor(first, after, last, before)
page, err := r.svc.ListEvidenceStateTransitions(ctx, obj.ID, cursor)
page, err := r.proboSvc.ListEvidenceStateTransitions(ctx, obj.ID, cursor)
if err != nil {
return nil, fmt.Errorf("cannot list evidence state transitions: %w", err)
}
@@ -57,7 +57,7 @@ func (r *evidenceResolver) StateTransisions(ctx context.Context, obj *types.Evid
func (r *frameworkResolver) Controls(ctx context.Context, obj *types.Framework, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.ControlConnection, error) {
cursor := types.NewCursor(first, after, last, before)
page, err := r.svc.ListFrameworkControls(ctx, obj.ID, cursor)
page, err := r.proboSvc.ListFrameworkControls(ctx, obj.ID, cursor)
if err != nil {
return nil, fmt.Errorf("cannot list framework controls: %w", err)
}
@@ -67,7 +67,7 @@ func (r *frameworkResolver) Controls(ctx context.Context, obj *types.Framework,
// CreateVendor is the resolver for the createVendor field.
func (r *mutationResolver) CreateVendor(ctx context.Context, input types.CreateVendorInput) (*types.CreateVendorPayload, error) {
vendor, err := r.svc.CreateVendor(ctx, probo.CreateVendorRequest{
vendor, err := r.proboSvc.CreateVendor(ctx, probo.CreateVendorRequest{
OrganizationID: input.OrganizationID,
Name: input.Name,
Description: input.Description,
@@ -89,7 +89,7 @@ func (r *mutationResolver) CreateVendor(ctx context.Context, input types.CreateV
// UpdateVendor is the resolver for the updateVendor field.
func (r *mutationResolver) UpdateVendor(ctx context.Context, input types.UpdateVendorInput) (*types.Vendor, error) {
vendor, err := r.svc.UpdateVendor(ctx, probo.UpdateVendorRequest{
vendor, err := r.proboSvc.UpdateVendor(ctx, probo.UpdateVendorRequest{
ID: input.ID,
ExpectedVersion: input.ExpectedVersion,
Name: input.Name,
@@ -111,7 +111,7 @@ func (r *mutationResolver) UpdateVendor(ctx context.Context, input types.UpdateV
// DeleteVendor is the resolver for the deleteVendor field.
func (r *mutationResolver) DeleteVendor(ctx context.Context, input types.DeleteVendorInput) (*types.DeleteVendorPayload, error) {
err := r.svc.DeleteVendor(ctx, input.VendorID)
err := r.proboSvc.DeleteVendor(ctx, input.VendorID)
if err != nil {
return nil, fmt.Errorf("cannot delete vendor: %w", err)
}
@@ -123,7 +123,7 @@ func (r *mutationResolver) DeleteVendor(ctx context.Context, input types.DeleteV
// CreatePeople is the resolver for the createPeople field.
func (r *mutationResolver) CreatePeople(ctx context.Context, input types.CreatePeopleInput) (*types.CreatePeoplePayload, error) {
people, err := r.svc.CreatePeople(ctx, probo.CreatePeopleRequest{
people, err := r.proboSvc.CreatePeople(ctx, probo.CreatePeopleRequest{
OrganizationID: input.OrganizationID,
FullName: input.FullName,
PrimaryEmailAddress: input.PrimaryEmailAddress,
@@ -142,7 +142,7 @@ func (r *mutationResolver) CreatePeople(ctx context.Context, input types.CreateP
// UpdatePeople is the resolver for the updatePeople field.
func (r *mutationResolver) UpdatePeople(ctx context.Context, input types.UpdatePeopleInput) (*types.People, error) {
people, err := r.svc.UpdatePeople(ctx, probo.UpdatePeopleRequest{
people, err := r.proboSvc.UpdatePeople(ctx, probo.UpdatePeopleRequest{
ID: input.ID,
ExpectedVersion: input.ExpectedVersion,
FullName: input.FullName,
@@ -159,7 +159,7 @@ func (r *mutationResolver) UpdatePeople(ctx context.Context, input types.UpdateP
// DeletePeople is the resolver for the deletePeople field.
func (r *mutationResolver) DeletePeople(ctx context.Context, input types.DeletePeopleInput) (*types.DeletePeoplePayload, error) {
err := r.svc.DeletePeople(ctx, input.PeopleID)
err := r.proboSvc.DeletePeople(ctx, input.PeopleID)
if err != nil {
return nil, fmt.Errorf("cannot delete people: %w", err)
}
@@ -173,7 +173,7 @@ func (r *mutationResolver) DeletePeople(ctx context.Context, input types.DeleteP
func (r *organizationResolver) Frameworks(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.FrameworkConnection, error) {
cursor := types.NewCursor(first, after, last, before)
page, err := r.svc.ListOrganizationFrameworks(ctx, obj.ID, cursor)
page, err := r.proboSvc.ListOrganizationFrameworks(ctx, obj.ID, cursor)
if err != nil {
return nil, fmt.Errorf("cannot list organization frameworks: %w", err)
}
@@ -185,7 +185,7 @@ func (r *organizationResolver) Frameworks(ctx context.Context, obj *types.Organi
func (r *organizationResolver) Vendors(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.VendorConnection, error) {
cursor := types.NewCursor(first, after, last, before)
page, err := r.svc.ListOrganizationVendors(ctx, obj.ID, cursor)
page, err := r.proboSvc.ListOrganizationVendors(ctx, obj.ID, cursor)
if err != nil {
return nil, fmt.Errorf("cannot list organization vendors: %w", err)
}
@@ -197,7 +197,7 @@ func (r *organizationResolver) Vendors(ctx context.Context, obj *types.Organizat
func (r *organizationResolver) Peoples(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.PeopleConnection, error) {
cursor := types.NewCursor(first, after, last, before)
page, err := r.svc.ListOrganizationPeoples(ctx, obj.ID, cursor)
page, err := r.proboSvc.ListOrganizationPeoples(ctx, obj.ID, cursor)
if err != nil {
return nil, fmt.Errorf("cannot list organization peoples: %w", err)
}
@@ -209,49 +209,49 @@ func (r *organizationResolver) Peoples(ctx context.Context, obj *types.Organizat
func (r *queryResolver) Node(ctx context.Context, id gid.GID) (types.Node, error) {
switch id.EntityType() {
case coredata.OrganizationEntityType:
organization, err := r.svc.GetOrganization(ctx, id)
organization, err := r.proboSvc.GetOrganization(ctx, id)
if err != nil {
return nil, err
}
return types.NewOrganization(organization), nil
case coredata.PeopleEntityType:
people, err := r.svc.GetPeople(ctx, id)
people, err := r.proboSvc.GetPeople(ctx, id)
if err != nil {
return nil, err
}
return types.NewPeople(people), nil
case coredata.VendorEntityType:
vendor, err := r.svc.GetVendor(ctx, id)
vendor, err := r.proboSvc.GetVendor(ctx, id)
if err != nil {
return nil, err
}
return types.NewVendor(vendor), nil
case coredata.FrameworkEntityType:
framework, err := r.svc.GetFramework(ctx, id)
framework, err := r.proboSvc.GetFramework(ctx, id)
if err != nil {
return nil, err
}
return types.NewFramework(framework), nil
case coredata.ControlEntityType:
control, err := r.svc.GetControl(ctx, id)
control, err := r.proboSvc.GetControl(ctx, id)
if err != nil {
return nil, err
}
return types.NewControl(control), nil
case coredata.TaskEntityType:
task, err := r.svc.GetTask(ctx, id)
task, err := r.proboSvc.GetTask(ctx, id)
if err != nil {
return nil, err
}
return types.NewTask(task), nil
case coredata.EvidenceEntityType:
evidence, err := r.svc.GetEvidence(ctx, id)
evidence, err := r.proboSvc.GetEvidence(ctx, id)
if err != nil {
return nil, err
}
@@ -264,15 +264,21 @@ func (r *queryResolver) Node(ctx context.Context, id gid.GID) (types.Node, error
}
// Viewer is the resolver for the viewer field.
func (r *queryResolver) Viewer(ctx context.Context) (*types.Viewer, error) {
return &types.Viewer{}, nil
func (r *queryResolver) Viewer(ctx context.Context) (*types.User, error) {
user := UserFromContext(ctx)
return &types.User{
ID: user.ID,
Email: user.EmailAddress,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
}, nil
}
// StateTransisions is the resolver for the stateTransisions field.
func (r *taskResolver) StateTransisions(ctx context.Context, obj *types.Task, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.TaskStateTransitionConnection, error) {
cursor := types.NewCursor(first, after, last, before)
page, err := r.svc.ListTaskStateTransitions(ctx, obj.ID, cursor)
page, err := r.proboSvc.ListTaskStateTransitions(ctx, obj.ID, cursor)
if err != nil {
return nil, fmt.Errorf("cannot list control tasks: %w", err)
}
@@ -284,7 +290,7 @@ func (r *taskResolver) StateTransisions(ctx context.Context, obj *types.Task, fi
func (r *taskResolver) Evidences(ctx context.Context, obj *types.Task, first *int, after *page.CursorKey, last *int, before *page.CursorKey) (*types.EvidenceConnection, error) {
cursor := types.NewCursor(first, after, last, before)
page, err := r.svc.ListTaskEvidences(ctx, obj.ID, cursor)
page, err := r.proboSvc.ListTaskEvidences(ctx, obj.ID, cursor)
if err != nil {
return nil, fmt.Errorf("cannot list organization frameworks: %w", err)
}
@@ -293,11 +299,22 @@ func (r *taskResolver) Evidences(ctx context.Context, obj *types.Task, first *in
}
// Organization is the resolver for the organization field.
func (r *viewerResolver) Organization(ctx context.Context, obj *types.Viewer) (*types.Organization, error) {
organizationID, _ := gid.ParseGID("AZSfP_xAcAC5IAAAAAAltA") // TODO: remove this
organization, err := r.svc.GetOrganization(ctx, organizationID)
func (r *userResolver) Organization(ctx context.Context, obj *types.User) (*types.Organization, error) {
// Get the user's organization ID
organizationID, err := r.usrmgrSvc.GetUserOrganization(ctx, obj.ID)
if err != nil {
return nil, fmt.Errorf("cannot get organization: %w", err)
return nil, fmt.Errorf("failed to get user organization: %w", err)
}
// If the user doesn't have an organization, return nil
if organizationID == gid.Nil {
return nil, nil
}
// Get the organization details
organization, err := r.proboSvc.GetOrganization(ctx, organizationID)
if err != nil {
return nil, fmt.Errorf("failed to get organization details: %w", err)
}
return types.NewOrganization(organization), nil
@@ -324,8 +341,8 @@ func (r *Resolver) Query() schema.QueryResolver { return &queryResolver{r} }
// Task returns schema.TaskResolver implementation.
func (r *Resolver) Task() schema.TaskResolver { return &taskResolver{r} }
// Viewer returns schema.ViewerResolver implementation.
func (r *Resolver) Viewer() schema.ViewerResolver { return &viewerResolver{r} }
// User returns schema.UserResolver implementation.
func (r *Resolver) User() schema.UserResolver { return &userResolver{r} }
type controlResolver struct{ *Resolver }
type evidenceResolver struct{ *Resolver }
@@ -334,4 +351,4 @@ type mutationResolver struct{ *Resolver }
type organizationResolver struct{ *Resolver }
type queryResolver struct{ *Resolver }
type taskResolver struct{ *Resolver }
type viewerResolver struct{ *Resolver }
type userResolver struct{ *Resolver }

View File

@@ -31,6 +31,7 @@ var (
Nil = GID(uuid.Nil)
)
// ParseGID parses a string representation of a GID
func ParseGID(encoded string) (GID, error) {
gid := GID{}
@@ -42,6 +43,17 @@ func ParseGID(encoded string) (GID, error) {
return gid, nil
}
// New creates a new GID with a default entity type of 0
func New() GID {
id, err := NewGID(0)
if err != nil {
// This should never happen with a valid random source
panic(fmt.Sprintf("failed to generate GID: %v", err))
}
return id
}
// NewGID creates a new GID with the specified entity type
func NewGID(et uint32) (GID, error) {
id, err := uuid.NewV7()
if err != nil {

56
pkg/probod/auth_config.go Normal file
View File

@@ -0,0 +1,56 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package probod
import (
"encoding/base64"
"fmt"
)
type (
authConfig struct {
// Pepper is a secret key used for password hashing
// It should be at least 32 bytes long
Pepper string `json:"pepper"`
SessionDuration int `json:"session-duration"`
CookieName string `json:"cookie-name"`
CookieSecure bool `json:"cookie-secure"`
CookieHTTPOnly bool `json:"cookie-http-only"`
CookieDomain string `json:"cookie-domain"`
CookiePath string `json:"cookie-path"`
}
)
// GetPepperBytes returns the pepper as a byte array
func (c authConfig) GetPepperBytes() ([]byte, error) {
if c.Pepper == "" {
return nil, fmt.Errorf("pepper cannot be empty")
}
// If the pepper is base64 encoded, decode it
if decoded, err := base64.StdEncoding.DecodeString(c.Pepper); err == nil {
if len(decoded) < 32 {
return nil, fmt.Errorf("decoded pepper must be at least 32 bytes long")
}
return decoded, nil
}
// Otherwise use the raw string as the pepper
if len(c.Pepper) < 32 {
return nil, fmt.Errorf("pepper must be at least 32 bytes long")
}
return []byte(c.Pepper), nil
}

View File

@@ -24,6 +24,7 @@ import (
"time"
"github.com/getprobo/probo/pkg/api"
console_v1 "github.com/getprobo/probo/pkg/api/console/v1"
"github.com/getprobo/probo/pkg/probo"
"github.com/getprobo/probo/pkg/usrmgr"
"github.com/prometheus/client_golang/prometheus"
@@ -40,8 +41,9 @@ type (
}
config struct {
Pg pgConfig `json:"pg"`
Api apiConfig `json:"api"`
Pg pgConfig `json:"pg"`
Api apiConfig `json:"api"`
Auth authConfig `json:"auth"`
}
)
@@ -66,6 +68,15 @@ func New() *Implm {
Database: "probod",
PoolSize: 100,
},
Auth: authConfig{
Pepper: "this-is-a-secure-pepper-for-password-hashing-at-least-32-bytes",
SessionDuration: 24,
CookieName: "SSID",
CookieSecure: false,
CookieHTTPOnly: true,
CookieDomain: "localhost",
CookiePath: "/",
},
},
}
}
@@ -95,21 +106,36 @@ func (impl *Implm) Run(
return fmt.Errorf("cannot create pg client: %w", err)
}
usrmgr, err := usrmgr.NewService(ctx, pgClient)
// Get the pepper bytes for password hashing
pepper, err := impl.cfg.Auth.GetPepperBytes()
if err != nil {
return fmt.Errorf("cannot get pepper bytes: %w", err)
}
// Initialize the user management service with the pepper
usrmgrService, err := usrmgr.NewService(ctx, pgClient, pepper)
if err != nil {
return fmt.Errorf("cannot create usrmgr service: %w", err)
}
probo, err := probo.NewService(ctx, pgClient)
proboService, err := probo.NewService(ctx, pgClient)
if err != nil {
return fmt.Errorf("cannot create probo service: %w", err)
}
apiServer, err := api.NewServer(
api.Config{
Probo: probo,
Usrmgr: usrmgr,
Probo: proboService,
Usrmgr: usrmgrService,
AllowedOrigins: impl.cfg.Api.Cors.AllowedOrigins,
Auth: console_v1.AuthConfig{
CookieName: impl.cfg.Auth.CookieName,
CookieSecure: impl.cfg.Auth.CookieSecure,
CookieHTTPOnly: impl.cfg.Auth.CookieHTTPOnly,
CookieDomain: impl.cfg.Auth.CookieDomain,
CookiePath: impl.cfg.Auth.CookiePath,
SessionDuration: time.Duration(impl.cfg.Auth.SessionDuration) * time.Hour,
},
},
)
if err != nil {

View File

@@ -0,0 +1,5 @@
-- Add organization_id column to usrmgr_users table
ALTER TABLE usrmgr_users ADD COLUMN organization_id TEXT REFERENCES organizations(id);
-- Create an index for faster lookups
CREATE INDEX usrmgr_users_organization_id_idx ON usrmgr_users(organization_id);

View File

@@ -16,7 +16,6 @@ package coredata
import (
"context"
"fmt"
"time"
"github.com/getprobo/probo/pkg/gid"
@@ -57,17 +56,17 @@ func (s *Session) LoadByID(
q := `
SELECT
id,
user_id,
expired_at,
created_at,
updated_at
FROM
sessions
usrmgr_sessions
WHERE
id = @session_id
LIMIT 1;
`
q = fmt.Sprintf(q)
args := pgx.NamedArgs{"session_id": sessionID}
r := conn.QueryRow(ctx, q, args)
@@ -88,7 +87,7 @@ func (s *Session) Insert(
) error {
q := `
INSERT INTO
sessions (id, user_id, expired_at, created_at, updated_at)
usrmgr_sessions (id, user_id, expired_at, created_at, updated_at)
VALUES (
@session_id,
@user_id,
@@ -109,3 +108,44 @@ VALUES (
_, err := conn.Exec(ctx, q, args)
return err
}
func (s *Session) Update(
ctx context.Context,
conn pg.Conn,
) error {
q := `
UPDATE usrmgr_sessions
SET
expired_at = @expired_at,
updated_at = @updated_at
WHERE
id = @session_id
`
args := pgx.NamedArgs{
"session_id": s.ID,
"expired_at": s.ExpiredAt,
"updated_at": s.UpdatedAt,
}
_, err := conn.Exec(ctx, q, args)
return err
}
func DeleteSession(
ctx context.Context,
conn pg.Conn,
sessionID gid.GID,
) error {
q := `
DELETE FROM
usrmgr_sessions
WHERE
id = @session_id
`
args := pgx.NamedArgs{"session_id": sessionID}
_, err := conn.Exec(ctx, q, args)
return err
}

View File

@@ -29,6 +29,7 @@ type (
ID gid.GID
EmailAddress string
HashedPassword []byte
OrganizationID gid.GID
CreatedAt time.Time
UpdatedAt time.Time
}
@@ -43,6 +44,7 @@ func (u *User) scan(r pgx.Row) error {
&u.ID,
&u.EmailAddress,
&u.HashedPassword,
&u.OrganizationID,
&u.CreatedAt,
&u.UpdatedAt,
)
@@ -58,12 +60,13 @@ SELECT
id,
email_address,
hashed_password,
organization_id,
created_at,
updated_at
FROM
users
usrmgr_users
WHERE
email = @user_email
email_address = @user_email
LIMIT 1;
`
@@ -80,3 +83,67 @@ LIMIT 1;
return nil
}
func (u *User) LoadByID(
ctx context.Context,
conn pg.Conn,
userID gid.GID,
) error {
q := `
SELECT
id,
email_address,
hashed_password,
organization_id,
created_at,
updated_at
FROM
usrmgr_users
WHERE
id = @user_id
LIMIT 1;
`
args := pgx.NamedArgs{"user_id": userID}
r := conn.QueryRow(ctx, q, args)
u2 := User{}
if err := u2.scan(r); err != nil {
return err
}
*u = u2
return nil
}
func (u *User) Insert(
ctx context.Context,
conn pg.Conn,
) error {
q := `
INSERT INTO
usrmgr_users (id, email_address, hashed_password, organization_id, created_at, updated_at)
VALUES (
@user_id,
@email_address,
@hashed_password,
@organization_id,
@created_at,
@updated_at
)
`
args := pgx.NamedArgs{
"user_id": u.ID,
"email_address": u.EmailAddress,
"hashed_password": u.HashedPassword,
"organization_id": "AZSfP_xAcAC5IAAAAAAltA",
"created_at": u.CreatedAt,
"updated_at": u.UpdatedAt,
}
_, err := conn.Exec(ctx, q, args)
return err
}

View File

@@ -21,6 +21,7 @@ import (
"github.com/getprobo/probo/pkg/gid"
"github.com/getprobo/probo/pkg/usrmgr/coredata"
"github.com/jackc/pgx/v5"
"go.gearno.de/kit/migrator"
"go.gearno.de/kit/pg"
)
@@ -30,22 +31,116 @@ type (
pg *pg.Client
hp *HashingProfile
}
RegisterUserParams struct {
Email string
Password string
}
ErrInvalidCredentials struct {
message string
}
ErrUserAlreadyExists struct {
message string
}
ErrSessionNotFound struct {
message string
}
ErrSessionExpired struct {
message string
}
)
func (e ErrInvalidCredentials) Error() string {
return e.message
}
func (e ErrUserAlreadyExists) Error() string {
return e.message
}
func (e ErrSessionNotFound) Error() string {
return e.message
}
func (e ErrSessionExpired) Error() string {
return e.message
}
func NewService(
ctx context.Context,
pgClient *pg.Client,
pepper []byte,
) (*Service, error) {
err := migrator.NewMigrator(pgClient, coredata.Migrations).Run(ctx, "migrations")
if err != nil {
return nil, fmt.Errorf("cannot migrate database schema: %w", err)
}
hp, err := NewHashingProfile(pepper)
if err != nil {
return nil, fmt.Errorf("cannot create hashing profile: %w", err)
}
return &Service{
pg: pgClient,
hp: hp,
}, nil
}
func (s Service) RegisterUser(
ctx context.Context,
params RegisterUserParams,
) (*coredata.User, error) {
if params.Email == "" || params.Password == "" {
return nil, fmt.Errorf("email and password are required")
}
// Use a high iteration count for password hashing
const iterations = 600000
hashedPassword, err := s.hp.HashPassword([]byte(params.Password), iterations)
if err != nil {
return nil, fmt.Errorf("cannot hash password: %w", err)
}
now := time.Now()
user := &coredata.User{
ID: gid.New(),
EmailAddress: params.Email,
HashedPassword: hashedPassword,
CreatedAt: now,
UpdatedAt: now,
}
err = s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
// Check if user already exists
existingUser := &coredata.User{}
err := existingUser.LoadByEmail(ctx, tx, params.Email)
if err == nil {
return &ErrUserAlreadyExists{message: "user with this email already exists"}
}
// Insert the new user
if err := user.Insert(ctx, tx); err != nil {
return fmt.Errorf("cannot insert user: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return user, nil
}
func (s Service) Login(
ctx context.Context,
email string,
@@ -54,8 +149,8 @@ func (s Service) Login(
now := time.Now()
user := &coredata.User{}
session := &coredata.Session{
ID: gid.GID{},
UserID: user.ID,
ID: gid.New(),
UserID: gid.GID{}, // Will be set after user is loaded
ExpiredAt: now.Add(24 * time.Hour),
CreatedAt: now,
UpdatedAt: now,
@@ -65,18 +160,21 @@ func (s Service) Login(
ctx,
func(tx pg.Conn) error {
if err := user.LoadByEmail(ctx, tx, email); err != nil {
return fmt.Errorf("cannot load user by email: %w", err)
return &ErrInvalidCredentials{message: "invalid email or password"}
}
ok, err := s.hp.ComparePasswordAndHash([]byte(password), user.HashedPassword)
if err != nil {
return fmt.Errorf("cannot constant compare byte: %w", err)
return fmt.Errorf("cannot compare password: %w", err)
}
if !ok {
return fmt.Errorf("invalid password")
return &ErrInvalidCredentials{message: "invalid email or password"}
}
// Set the user ID in the session
session.UserID = user.ID
if err := session.Insert(ctx, tx); err != nil {
return fmt.Errorf("cannot insert session: %w", err)
}
@@ -89,13 +187,176 @@ func (s Service) Login(
return nil, err
}
return nil, nil
return session, nil
}
func (s Service) Logout(sessionID string) error {
return nil
func (s Service) Logout(
ctx context.Context,
sessionID gid.GID,
) error {
return s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
return coredata.DeleteSession(ctx, tx, sessionID)
},
)
}
func (s Service) GetSession(sessionID string) (*coredata.Session, error) {
return nil, nil
func (s Service) GetSession(
ctx context.Context,
sessionID gid.GID,
) (*coredata.Session, error) {
session := &coredata.Session{}
err := s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
if err := session.LoadByID(ctx, tx, sessionID); err != nil {
return &ErrSessionNotFound{message: "session not found"}
}
// Check if session is expired
if time.Now().After(session.ExpiredAt) {
// Delete expired session
if err := coredata.DeleteSession(ctx, tx, sessionID); err != nil {
return fmt.Errorf("cannot delete expired session: %w", err)
}
return &ErrSessionExpired{message: "session expired"}
}
return nil
},
)
if err != nil {
return nil, err
}
return session, nil
}
func (s Service) RefreshSession(
ctx context.Context,
sessionID gid.GID,
) (*coredata.Session, error) {
session := &coredata.Session{}
err := s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
if err := session.LoadByID(ctx, tx, sessionID); err != nil {
return &ErrSessionNotFound{message: "session not found"}
}
// Check if session is expired
if time.Now().After(session.ExpiredAt) {
return &ErrSessionExpired{message: "session expired"}
}
// Update session expiration
now := time.Now()
session.ExpiredAt = now.Add(24 * time.Hour)
session.UpdatedAt = now
if err := session.Update(ctx, tx); err != nil {
return fmt.Errorf("cannot update session: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return session, nil
}
func (s Service) GetUserByID(
ctx context.Context,
userID gid.GID,
) (*coredata.User, error) {
user := &coredata.User{}
err := s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
if err := user.LoadByID(ctx, tx, userID); err != nil {
return fmt.Errorf("user not found: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return user, nil
}
func (s Service) GetUserBySession(
ctx context.Context,
sessionID gid.GID,
) (*coredata.User, error) {
session, err := s.GetSession(ctx, sessionID)
if err != nil {
return nil, err
}
return s.GetUserByID(ctx, session.UserID)
}
// SetUserOrganization sets the organization for a user
func (s Service) SetUserOrganization(
ctx context.Context,
userID gid.GID,
organizationID gid.GID,
) error {
return s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
user := &coredata.User{}
if err := user.LoadByID(ctx, tx, userID); err != nil {
return fmt.Errorf("user not found: %w", err)
}
// Update the organization ID
user.OrganizationID = organizationID
user.UpdatedAt = time.Now()
// Update the user in the database
q := `
UPDATE usrmgr_users
SET organization_id = @organization_id, updated_at = @updated_at
WHERE id = @user_id
`
args := pgx.NamedArgs{
"user_id": user.ID,
"organization_id": user.OrganizationID,
"updated_at": user.UpdatedAt,
}
_, err := tx.Exec(ctx, q, args)
if err != nil {
return fmt.Errorf("cannot update user organization: %w", err)
}
return nil
},
)
}
// GetUserOrganization gets the organization ID for a user
func (s Service) GetUserOrganization(
ctx context.Context,
userID gid.GID,
) (gid.GID, error) {
user, err := s.GetUserByID(ctx, userID)
if err != nil {
return gid.GID{}, err
}
return user.OrganizationID, nil
}