From e9ae77a4a048ae4edc442279ab8f00b3e6bc5342 Mon Sep 17 00:00:00 2001 From: gearnode Date: Tue, 25 Feb 2025 17:32:26 +0100 Subject: [PATCH] Add login/register logic Signed-off-by: gearnode --- apps/console/src/App.tsx | 284 ++++--- .../console/src/components/ProtectedRoute.tsx | 20 + .../__generated__/AppSidebarQuery.graphql.ts | 6 +- apps/console/src/contexts/AuthContext.tsx | 78 ++ apps/console/src/layouts/AuthLayout.tsx | 40 + apps/console/src/pages/LoginPage.tsx | 153 ++++ apps/console/src/pages/RegisterPage.tsx | 155 ++++ .../CreatePeoplePageQuery.graphql.ts | 4 +- .../FrameworkListPageQuery.graphql.ts | 6 +- .../__generated__/HomePageQuery.graphql.ts | 6 +- .../PeopleListPageQuery.graphql.ts | 6 +- .../SettingsPageQuery.graphql.ts | 6 +- .../VendorListPageQuery.graphql.ts | 6 +- pkg/api/api.go | 11 +- pkg/api/console/v1/auth_handlers.go | 203 +++++ pkg/api/console/v1/cookie.go | 35 + pkg/api/console/v1/resolver.go | 113 ++- pkg/api/console/v1/schema.graphql | 21 +- pkg/api/console/v1/schema/schema.go | 731 +++++++++++++----- pkg/api/console/v1/types/types.go | 18 +- pkg/api/console/v1/v1_resolver.go | 79 +- pkg/gid/gid.go | 12 + pkg/probod/auth_config.go | 56 ++ pkg/probod/probod.go | 38 +- .../coredata/migrations/20250225T153220Z.sql | 5 + pkg/usrmgr/coredata/session.go | 50 +- pkg/usrmgr/coredata/user.go | 71 +- pkg/usrmgr/usrmgr.go | 281 ++++++- 28 files changed, 2075 insertions(+), 419 deletions(-) create mode 100644 apps/console/src/components/ProtectedRoute.tsx create mode 100644 apps/console/src/contexts/AuthContext.tsx create mode 100644 apps/console/src/layouts/AuthLayout.tsx create mode 100644 apps/console/src/pages/LoginPage.tsx create mode 100644 apps/console/src/pages/RegisterPage.tsx create mode 100644 pkg/api/console/v1/auth_handlers.go create mode 100644 pkg/api/console/v1/cookie.go create mode 100644 pkg/probod/auth_config.go create mode 100644 pkg/usrmgr/coredata/migrations/20250225T153220Z.sql diff --git a/apps/console/src/App.tsx b/apps/console/src/App.tsx index a2055396a..85fd53818 100644 --- a/apps/console/src/App.tsx +++ b/apps/console/src/App.tsx @@ -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() { - - - - - } - > + + + {/* Authentication Routes - Accessible without login */} + + + + } + > + + + + + + } + /> + + + + + + } + > + + + + + + } + /> + + + {/* Protected Routes - Require authentication */} + }> + - + - - } - /> - - - - - - } - /> - - - - - - } - /> - - - - - - } - /> - - - - - - } - /> - - - - - - } - /> - - - - - - } - /> - - - - - - } - /> - - - - - - } - /> - - - - - - } - /> - - - - } - /> - - + } + > + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + + + } + /> + + + + } + /> + + + + diff --git a/apps/console/src/components/ProtectedRoute.tsx b/apps/console/src/components/ProtectedRoute.tsx new file mode 100644 index 000000000..8d43627f6 --- /dev/null +++ b/apps/console/src/components/ProtectedRoute.tsx @@ -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 ; + } + + // Render the protected content + return ; +} diff --git a/apps/console/src/components/__generated__/AppSidebarQuery.graphql.ts b/apps/console/src/components/__generated__/AppSidebarQuery.graphql.ts index e34fc2361..e5250010f 100644 --- a/apps/console/src/components/__generated__/AppSidebarQuery.graphql.ts +++ b/apps/console/src/components/__generated__/AppSidebarQuery.graphql.ts @@ -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, diff --git a/apps/console/src/contexts/AuthContext.tsx b/apps/console/src/contexts/AuthContext.tsx new file mode 100644 index 000000000..aa44880e1 --- /dev/null +++ b/apps/console/src/contexts/AuthContext.tsx @@ -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; + logout: () => Promise; +} + +const AuthContext = createContext(undefined); + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const navigate = useNavigate(); + + const checkAuth = async (): Promise => { + 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 => { + try { + await fetch(buildEndpoint("/console/v1/auth/logout"), { + method: "POST", + credentials: "include", + }); + } finally { + setIsAuthenticated(false); + navigate("/login"); + } + }; + + useEffect(() => { + checkAuth(); + }, []); + + return ( + + {children} + + ); +} + +export function useAuth(): AuthContextType { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return context; +} diff --git a/apps/console/src/layouts/AuthLayout.tsx b/apps/console/src/layouts/AuthLayout.tsx new file mode 100644 index 000000000..5ee8b6295 --- /dev/null +++ b/apps/console/src/layouts/AuthLayout.tsx @@ -0,0 +1,40 @@ +import { Outlet } from "react-router"; +import { Toaster } from "@/components/ui/toaster"; + +export default function AuthLayout() { + return ( +
+
+
+
+
+ +
+
+
+
+
+
+
+ Probo Mascot +
+
+

+ Navigate compliance with +
+ confidence thanks to{" "} + probo +

+
+
+
+
+
+ +
+ ); +} diff --git a/apps/console/src/pages/LoginPage.tsx b/apps/console/src/pages/LoginPage.tsx new file mode 100644 index 000000000..5596f2fa8 --- /dev/null +++ b/apps/console/src/pages/LoginPage.tsx @@ -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 ( + <> + + Log in - Probo + + +
+
+

Log in

+

+ Enter your credentials to access your account +

+
+ +
+
+ + setEmail(e.target.value)} + required + /> +
+ +
+ + setPassword(e.target.value)} + required + /> +
+ + +
+ +
+

+ Don't have an account?{" "} + + Sign up here + +

+
+ +
+
+ +
+
+ + Or continue with + +
+
+ + +
+ + ); +} diff --git a/apps/console/src/pages/RegisterPage.tsx b/apps/console/src/pages/RegisterPage.tsx new file mode 100644 index 000000000..2dbc2f677 --- /dev/null +++ b/apps/console/src/pages/RegisterPage.tsx @@ -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 ( + <> + + Sign up - Probo + + +
+
+

Sign up

+

+ Enter your information to create an account +

+
+ +
+
+ + setEmail(e.target.value)} + required + /> +
+ +
+ + setPassword(e.target.value)} + required + /> +
+ + +
+ +
+

+ Already have an account?{" "} + + Log in here + +

+
+ +
+
+ +
+
+ + Or continue with + +
+
+ + +
+ + ); +} diff --git a/apps/console/src/pages/__generated__/CreatePeoplePageQuery.graphql.ts b/apps/console/src/pages/__generated__/CreatePeoplePageQuery.graphql.ts index b1d14ee8c..bb64ad454 100644 --- a/apps/console/src/pages/__generated__/CreatePeoplePageQuery.graphql.ts +++ b/apps/console/src/pages/__generated__/CreatePeoplePageQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<<25866c1f4e6fb640f91b8d05651df9f4>> + * @generated SignedSource<> * @lightSyntaxTransform * @nogrep */ @@ -36,7 +36,7 @@ v1 = [ { "alias": null, "args": null, - "concreteType": "Viewer", + "concreteType": "User", "kind": "LinkedField", "name": "viewer", "plural": false, diff --git a/apps/console/src/pages/__generated__/FrameworkListPageQuery.graphql.ts b/apps/console/src/pages/__generated__/FrameworkListPageQuery.graphql.ts index 48d14fe85..27c31dd83 100644 --- a/apps/console/src/pages/__generated__/FrameworkListPageQuery.graphql.ts +++ b/apps/console/src/pages/__generated__/FrameworkListPageQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<> + * @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, diff --git a/apps/console/src/pages/__generated__/HomePageQuery.graphql.ts b/apps/console/src/pages/__generated__/HomePageQuery.graphql.ts index cad08a8ff..4458ccfea 100644 --- a/apps/console/src/pages/__generated__/HomePageQuery.graphql.ts +++ b/apps/console/src/pages/__generated__/HomePageQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<> + * @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, diff --git a/apps/console/src/pages/__generated__/PeopleListPageQuery.graphql.ts b/apps/console/src/pages/__generated__/PeopleListPageQuery.graphql.ts index 193269616..16e0c6cd1 100644 --- a/apps/console/src/pages/__generated__/PeopleListPageQuery.graphql.ts +++ b/apps/console/src/pages/__generated__/PeopleListPageQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<> + * @generated SignedSource<> * @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, diff --git a/apps/console/src/pages/__generated__/SettingsPageQuery.graphql.ts b/apps/console/src/pages/__generated__/SettingsPageQuery.graphql.ts index 8e8a801d7..fb45a07dd 100644 --- a/apps/console/src/pages/__generated__/SettingsPageQuery.graphql.ts +++ b/apps/console/src/pages/__generated__/SettingsPageQuery.graphql.ts @@ -1,5 +1,5 @@ /** - * @generated SignedSource<> + * @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, diff --git a/apps/console/src/pages/__generated__/VendorListPageQuery.graphql.ts b/apps/console/src/pages/__generated__/VendorListPageQuery.graphql.ts index be1c6e556..a8ea17e53 100644 --- a/apps/console/src/pages/__generated__/VendorListPageQuery.graphql.ts +++ b/apps/console/src/pages/__generated__/VendorListPageQuery.graphql.ts @@ -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, diff --git a/pkg/api/api.go b/pkg/api/api.go index 548689366..55096576d 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -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) } diff --git a/pkg/api/console/v1/auth_handlers.go b/pkg/api/console/v1/auth_handlers.go new file mode 100644 index 000000000..e95514140 --- /dev/null +++ b/pkg/api/console/v1/auth_handlers.go @@ -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)) +} diff --git a/pkg/api/console/v1/cookie.go b/pkg/api/console/v1/cookie.go new file mode 100644 index 000000000..60643cbf3 --- /dev/null +++ b/pkg/api/console/v1/cookie.go @@ -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) +} diff --git a/pkg/api/console/v1/resolver.go b/pkg/api/console/v1/resolver.go index bc6818b26..faf2b034e 100644 --- a/pkg/api/console/v1/resolver.go +++ b/pkg/api/console/v1/resolver.go @@ -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) } } diff --git a/pkg/api/console/v1/schema.graphql b/pkg/api/console/v1/schema.graphql index b392d42b4..249247ab8 100644 --- a/pkg/api/console/v1/schema.graphql +++ b/pkg/api/console/v1/schema.graphql @@ -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 { diff --git a/pkg/api/console/v1/schema/schema.go b/pkg/api/console/v1/schema/schema.go index c3bdd9a43..6f6ff3aff 100644 --- a/pkg/api/console/v1/schema/schema.go +++ b/pkg/api/console/v1/schema/schema.go @@ -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) } diff --git a/pkg/api/console/v1/types/types.go b/pkg/api/console/v1/types/types.go index 84b27d038..35254d7fc 100644 --- a/pkg/api/console/v1/types/types.go +++ b/pkg/api/console/v1/types/types.go @@ -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"` -} diff --git a/pkg/api/console/v1/v1_resolver.go b/pkg/api/console/v1/v1_resolver.go index 32b7bd2ea..e782f9b93 100644 --- a/pkg/api/console/v1/v1_resolver.go +++ b/pkg/api/console/v1/v1_resolver.go @@ -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 } diff --git a/pkg/gid/gid.go b/pkg/gid/gid.go index e7c63cd43..fe0b3d837 100644 --- a/pkg/gid/gid.go +++ b/pkg/gid/gid.go @@ -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 { diff --git a/pkg/probod/auth_config.go b/pkg/probod/auth_config.go new file mode 100644 index 000000000..eb5a9b836 --- /dev/null +++ b/pkg/probod/auth_config.go @@ -0,0 +1,56 @@ +// Copyright (c) 2025 Probo Inc . +// +// 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 +} diff --git a/pkg/probod/probod.go b/pkg/probod/probod.go index 51c451acc..ffd7568a2 100644 --- a/pkg/probod/probod.go +++ b/pkg/probod/probod.go @@ -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 { diff --git a/pkg/usrmgr/coredata/migrations/20250225T153220Z.sql b/pkg/usrmgr/coredata/migrations/20250225T153220Z.sql new file mode 100644 index 000000000..bbaeff666 --- /dev/null +++ b/pkg/usrmgr/coredata/migrations/20250225T153220Z.sql @@ -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); \ No newline at end of file diff --git a/pkg/usrmgr/coredata/session.go b/pkg/usrmgr/coredata/session.go index 1821a2aa4..85ae59d7c 100644 --- a/pkg/usrmgr/coredata/session.go +++ b/pkg/usrmgr/coredata/session.go @@ -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 +} diff --git a/pkg/usrmgr/coredata/user.go b/pkg/usrmgr/coredata/user.go index 46ac0d976..7d2c34165 100644 --- a/pkg/usrmgr/coredata/user.go +++ b/pkg/usrmgr/coredata/user.go @@ -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 +} diff --git a/pkg/usrmgr/usrmgr.go b/pkg/usrmgr/usrmgr.go index e4b2b3fbb..65561f69a 100644 --- a/pkg/usrmgr/usrmgr.go +++ b/pkg/usrmgr/usrmgr.go @@ -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 }