1
0
Fork 0
mirror of https://codeberg.org/MarkusThielker/next-ory.git synced 2025-04-11 04:08:43 +00:00

NORY-36: add ory keto to application stack ()

This introduces the protection of the admin dashboard using Ory Keto. 
A script for applying the `admin` role to an identity has been added.
This commit is contained in:
Markus Thielker 2024-12-17 18:43:42 +01:00 committed by GitHub
commit b6ce48e03e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 356 additions and 72 deletions

View file

@ -1,15 +1,14 @@
# Next-Ory
Get started with ORY authentication quickly and easily.
Get started with the Ory stack quickly and easily.
> [!Warning]
> This project is work in progress. There is no guarantee that everything will work as it should and breaking changes in
> the future are possible.
The goal of this project is to create an easy-to-use setup to self-host [Ory Kratos](https://www.ory.sh/kratos)
and [Ory Hydra](https://www.ory.sh/hydra). It will contain an authentication UI, implementing all self-service flows for
Ory Kratos and Ory Hydra, as well as an admin UI. All UI components are written in NextJS and Typescript, and styled
using shadcn/ui and TailwindCSS.
The goal of this project is to create an easy-to-use setup to self-host the [Ory](https://www.ory.sh) stack with all its
components. It will contain an authentication UI, implementing all self-service flows for Ory Kratos and Ory Hydra, as
well as an admin UI. All UI components are written in NextJS and Typescript, and styled using shadcn/ui and TailwindCSS.
## Getting started
@ -41,6 +40,15 @@ bun install
bun run dev
```
To access the admin dashboard, the `identity` has to be a `member` of the `admin` role. (Relation: roles:admin@<
identity_id>#member) <br/>
The identity ID is displayed on the screen when accessing the dashboard without sufficient permissions. <br/>
Use the identity ID to execute the following script with the identity ID as an argument.
```bash
sh docker/ory-dev/keto-make-admin.sh <identity_id>
```
## Deployment
*soon.*
@ -58,11 +66,10 @@ Hydra. It is implemented in a way, that customizing style and page layout is ver
## Admin Dashboard
Right now I am working on the admin dashboard for Ory Kratos. It will provide you with an overview of your instance and
let you manage users, OAuth2 applications and more. It is ***work in progress*** and should not be used in anything
important as it is not yet protected by Keto permissions but only by a valid Kratos session!
Right now I am working on the admin dashboard for all Ory applications. It will provide you with an overview of your
instances and let you manage users, OAuth2 applications and more. It is ***work in progress*** and should be handled
with caution.
![A browser window showing the home page of the dashboard UI in dark mode](./documentation/.img/d-dashboard-dark.png)
![A browser window showing the users page of the dashboard UI in dark mode](./documentation/.img/d-users-dark.png)

View file

@ -1,6 +1,6 @@
# Next-Ory - Authentication
This directory contains a NextJS 14 (app router) UI Node, implementing all Ory Kratos and Ory Hydra UI flows.
This directory contains a NextJS 15 (app router) UI Node, implementing all Ory Kratos and Ory Hydra UI flows.
## Stack

View file

@ -11,7 +11,7 @@ const inter = Inter({ subsets: ['latin'] });
const APP_NAME = 'Next Ory';
const APP_DEFAULT_TITLE = 'Next Ory';
const APP_TITLE_TEMPLATE = `%s | ${APP_DEFAULT_TITLE}`;
const APP_DESCRIPTION = 'Get started with ORY authentication quickly and easily.';
const APP_DESCRIPTION = 'Get started with Ory authentication quickly and easily.';
export const metadata = {
applicationName: APP_NAME,

View file

@ -1,6 +1,6 @@
# Next-Ory - Dashboard
This directory contains a NextJS 15 (app router) UI Node, implementing the admin dashboard to the ORY Kratos instance.
This directory contains a NextJS 15 (app router) UI Node, implementing the admin dashboard for the Ory admin APIs.
## Stack

View file

@ -0,0 +1,37 @@
import '../globals.css';
import { Toaster } from '@/components/ui/sonner';
import React from 'react';
import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar';
import { AppSidebar } from '@/components/app-sidebar';
import { Separator } from '@/components/ui/separator';
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList } from '@/components/ui/breadcrumb';
export default function InsideLayout({ children }: Readonly<{ children: React.ReactNode }>) {
return (
<SidebarProvider className="max-h-screen min-h-screen">
<AppSidebar className="mx-1"/>
<SidebarInset className="overflow-hidden p-6 space-y-6">
<header className="flex h-4 items-center gap-2">
<SidebarTrigger className="-ml-1 p-1"/>
<Separator orientation="vertical" className="mr-2 h-4"/>
{
// TODO: implement dynamic Breadcrumbs
}
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem className="hidden md:block">
<BreadcrumbLink href="/">
Ory Dashboard
</BreadcrumbLink>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</header>
<div className="flex-1 overflow-scroll">
{children}
</div>
</SidebarInset>
<Toaster/>
</SidebarProvider>
);
}

View file

@ -1,4 +1,4 @@
import { getHydraMetadataApi, getKratosMetadataApi } from '@/ory/sdk/server';
import { getHydraMetadataApi, getKetoMetadataApi, getKratosMetadataApi } from '@/ory/sdk/server';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
@ -17,6 +17,7 @@ export default async function RootPage() {
const kratosDBStatusData = await fetch(process.env.ORY_KRATOS_ADMIN_URL + '/health/ready');
const kratosDBStatus = await kratosDBStatusData.json() as { status: string };
const hydraMetadataApi = await getHydraMetadataApi();
const hydraVersion = await hydraMetadataApi
@ -30,13 +31,27 @@ export default async function RootPage() {
const hydraDBStatusData = await fetch(process.env.ORY_KRATOS_ADMIN_URL + '/health/ready');
const hydraDBStatus = await hydraDBStatusData.json() as { status: string };
const ketoMetadataApi = await getKetoMetadataApi();
const ketoVersion = await ketoMetadataApi
.getVersion()
.then(res => res.data.version)
.catch(() => '');
const ketoStatusData = await fetch(process.env.ORY_KETO_ADMIN_URL + '/health/alive');
const ketoStatus = await ketoStatusData.json() as { status: string };
const ketoDBStatusData = await fetch(process.env.ORY_KETO_ADMIN_URL + '/health/ready');
const ketoDBStatus = await ketoDBStatusData.json() as { status: string };
return (
<div className="flex flex-col space-y-4">
<div>
<p className="text-3xl font-bold leading-tight tracking-tight">Software Stack</p>
<p className="text-lg font-light">See the list of all applications in your stack</p>
</div>
<div className="flex flex-row space-x-4">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className="flex-1">
<CardHeader>
<CardTitle>
@ -73,7 +88,24 @@ export default async function RootPage() {
</Badge>
</CardContent>
</Card>
<div className="flex-1"></div>
<Card className="flex-1">
<CardHeader>
<CardTitle>
Ory Keto
</CardTitle>
<CardDescription>
Version {ketoVersion}
</CardDescription>
</CardHeader>
<CardContent className="space-x-1">
<Badge variant={kratosStatus.status === 'ok' ? 'success' : 'destructive'}>
Keto {ketoStatus.status.toUpperCase()}
</Badge>
<Badge variant={kratosStatus.status === 'ok' ? 'success' : 'destructive'}>
Database {ketoDBStatus.status.toUpperCase()}
</Badge>
</CardContent>
</Card>
<div className="flex-1"></div>
</div>
</div>

View file

@ -6,7 +6,7 @@ import { DataTable } from '@/components/ui/data-table';
import { CircleCheck, CircleX, Copy, MoreHorizontal, Trash, UserCheck, UserMinus, UserPen, UserX } from 'lucide-react';
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card';
import React, { useEffect, useRef, useState } from 'react';
import { FetchIdentityPageProps } from '@/app/user/page';
import { FetchIdentityPageProps } from '@/app/(inside)/user/page';
import { Spinner } from '@/components/ui/spinner';
import {
DropdownMenu,
@ -29,7 +29,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { blockIdentity, deleteIdentity, deleteIdentitySessions, unblockIdentity } from '@/app/user/action';
import { blockIdentity, deleteIdentity, deleteIdentitySessions, unblockIdentity } from '@/app/(inside)/user/action';
interface IdentityDataTableProps {
data: Identity[];

View file

@ -1,5 +1,5 @@
import React from 'react';
import { IdentityDataTable } from '@/app/user/data-table';
import { IdentityDataTable } from '@/app/(inside)/user/data-table';
import { getIdentityApi } from '@/ory/sdk/server';
import { SearchInput } from '@/components/search-input';

View file

@ -0,0 +1,39 @@
'use client';
import { ErrorDisplay } from '@/components/error';
import { Button } from '@/components/ui/button';
import { kratos, LogoutLink } from '@/ory';
import { LogOut } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Session } from '@ory/client';
import { Skeleton } from '@/components/ui/skeleton';
export default function UnauthorizedPage() {
const [session, setSession] = useState<Session | undefined>(undefined);
useEffect(() => {
kratos.toSession()
.then((response) => setSession(response.data));
}, []);
return (
<div className="bg-background min-h-screen p-16">
<ErrorDisplay
title="Unauthorised"
message="You are unauthorised to access this application!"/>
{
session ?
<p className="text-xs text-neutral-500">USER ID {session.identity?.id}</p>
:
<Skeleton className="w-72 h-4"/>
}
<Button className="mt-8 space-x-2" onClick={LogoutLink()}>
<LogOut className="h-4 w-4"/>
<span>Logout</span>
</Button>
</div>
);
}

View file

@ -2,20 +2,15 @@ import type { Viewport } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { cn } from '@/lib/utils';
import { Toaster } from '@/components/ui/sonner';
import React from 'react';
import { ThemeProvider } from 'next-themes';
import { SidebarInset, SidebarProvider, SidebarTrigger } from '@/components/ui/sidebar';
import { AppSidebar } from '@/components/app-sidebar';
import { Separator } from '@/components/ui/separator';
import { Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList } from '@/components/ui/breadcrumb';
const inter = Inter({ subsets: ['latin'] });
const APP_NAME = 'Next Ory';
const APP_DEFAULT_TITLE = 'Next Ory';
const APP_TITLE_TEMPLATE = `%s | ${APP_DEFAULT_TITLE}`;
const APP_DESCRIPTION = 'Get started with ORY authentication quickly and easily.';
const APP_DESCRIPTION = 'Get started with Ory authentication quickly and easily.';
export const metadata = {
applicationName: APP_NAME,
@ -56,31 +51,7 @@ export default function RootLayout({ children }: Readonly<{ children: React.Reac
enableSystem
disableTransitionOnChange
>
<SidebarProvider className="max-h-screen min-h-screen">
<AppSidebar className="mx-1"/>
<SidebarInset className="overflow-hidden p-6 space-y-6">
<header className="flex h-4 items-center gap-2">
<SidebarTrigger className="-ml-1 p-1"/>
<Separator orientation="vertical" className="mr-2 h-4"/>
{
// TODO: implement dynamic Breadcrumbs
}
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem className="hidden md:block">
<BreadcrumbLink href="/">
Ory Dashboard
</BreadcrumbLink>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</header>
<div className="flex-1 overflow-scroll">
{children}
</div>
</SidebarInset>
<Toaster/>
</SidebarProvider>
{children}
</ThemeProvider>
</body>
</html>

View file

@ -3,11 +3,11 @@ interface ErrorDisplayProps {
message: string;
}
export async function ErrorDisplay({ title, message }: ErrorDisplayProps) {
export function ErrorDisplay({ title, message }: ErrorDisplayProps) {
return (
<div className="space-y-4">
<>
<p className="text-3xl font-bold leading-tight tracking-tight">{title}</p>
<p className="text-lg font-light">{message}</p>
</div>
</>
);
}

View file

@ -1,13 +1,13 @@
import { NextResponse } from 'next/server';
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { getFrontendApi } from '@/ory/sdk/server';
import { getFrontendApi, getPermissionApi } from '@/ory/sdk/server';
export async function middleware() {
export async function middleware(request: NextRequest) {
const api = await getFrontendApi();
const frontendApi = await getFrontendApi();
const cookie = await cookies();
const session = await api
const session = await frontendApi
.toSession({ cookie: 'ory_kratos_session=' + cookie.get('ory_kratos_session')?.value })
.then((response) => response.data)
.catch(() => null);
@ -25,7 +25,40 @@ export async function middleware() {
return NextResponse.redirect(url);
}
return NextResponse.next();
const permissionApi = await getPermissionApi();
const isAdmin = await permissionApi.checkPermission({
namespace: 'roles',
object: 'admin',
relation: 'member',
subjectId: session!.identity!.id,
})
.then(({ data: { allowed } }) => {
console.log('is_admin', session!.identity!.id, allowed);
return allowed;
})
.catch((response) => {
console.log('is_admin', session!.identity!.id, response, 'check failed');
return false;
});
if (isAdmin) {
if (request.nextUrl.pathname === '/unauthorised') {
return redirect('/', 'HAS PERMISSION BUT ACCESSING /unauthorized');
}
return NextResponse.next();
} else {
if (request.nextUrl.pathname === '/unauthorised') {
return NextResponse.next();
}
return redirect('/unauthorised', 'MISSING SESSION');
}
}
function redirect(path: string, reason: string) {
console.log(reason);
const url = `${process.env.NEXT_PUBLIC_DASHBOARD_NODE_URL}${path}`;
console.log('REDIRECT TO', url);
return NextResponse.redirect(url!);
}
export const config = {

View file

@ -1,6 +1,14 @@
'use server';
import { Configuration, FrontendApi, IdentityApi, MetadataApi, OAuth2Api } from '@ory/client';
import {
Configuration,
FrontendApi,
IdentityApi,
MetadataApi,
OAuth2Api,
PermissionApi,
RelationshipApi,
} from '@ory/client';
// ####################################################################################
@ -92,3 +100,57 @@ const kratosMetadataApi = new MetadataApi(
export async function getKratosMetadataApi() {
return kratosMetadataApi;
}
// ####################################################################################
// Relationship API
// ####################################################################################
const relationshipApi = new RelationshipApi(new Configuration(
{
basePath: process.env.ORY_KETO_ADMIN_URL,
baseOptions: {
withCredentials: true,
},
},
));
export async function getRelationshipApi() {
return relationshipApi;
}
// ####################################################################################
// Permission API
// ####################################################################################
const permissionApi = new PermissionApi(new Configuration(
{
basePath: process.env.ORY_KETO_ADMIN_URL,
baseOptions: {
withCredentials: true,
},
},
));
export async function getPermissionApi() {
return permissionApi;
}
// ####################################################################################
// Keto Metadata API
// ####################################################################################
const ketoMetadataApi = new MetadataApi(new Configuration(
{
basePath: process.env.ORY_KETO_ADMIN_URL,
baseOptions: {
withCredentials: true,
},
},
));
export async function getKetoMetadataApi() {
return ketoMetadataApi;
}

View file

@ -1,6 +1,6 @@
# Starting as a container
Starting this project in a container makes testing it really easy. \
Starting this project in a container makes testing it really easy.
```bash
# move to the environment you want to start (here development)
@ -17,7 +17,7 @@ sh ./hydra-test-consent.sh
```
These commands will start up multiple containers in the background.
Then continue with starting the authentication UI development server as described in the authentication README.
Then continue with starting the authentication UI development server as described in the root README.
## Services and Ports
@ -28,13 +28,15 @@ If you start up the environment on a remote server, you will need to tunnel the
| Service | Port (Public) | Description |
|----------------|---------------|---------------------------------------------------------------------------|
| Console | 4411 (✗) | Admin dashboard for Kratos data management (soon) |
| Console | 4000 (✗) | Admin dashboard for Kratos data management (soon) |
| Authentication | 3000 (✗) | User interface for authentication and account management (no docker yet) |
| ORY Kratos | 4433 (✗) | User management system handling users and self-service flows (Public API) |
| ORY Kratos | 4434 (✗) | User management system handling users and self-service flows (Admin API) |
| Ory Kratos | 4433 (✗) | User management system handling users and self-service flows (Public API) |
| | 4434 (✗) | User management system handling users and self-service flows (Admin API) |
| Mailslurper | 4436 (✗) | Mock mailing server (Dashboard) |
| Mailslurper | 4437 (✗) | Mock mailing server (API) |
| ORY Hydra | 4444 (✗) | OAuth2 and OIDC server connected to Kratos (Public API) |
| ORY Hydra | 4445 (✗) | OAuth2 and OIDC server connected to Kratos (Admin API) |
| ORY Hydra | 5555 (✗) | Hydra test application to test the consent flow |
| | 4437 (✗) | Mock mailing server (API) |
| Ory Hydra | 4444 (✗) | OAuth2 and OIDC server connected to Kratos (Public API) |
| | 4445 (✗) | OAuth2 and OIDC server connected to Kratos (Admin API) |
| | 5555 (✗) | Hydra test application to test the consent flow |
| Ory Keto | 4466 (✗) | Read Endpoint for Ory Keto authorization ("Public" API) |
| | 4467 (✗) | Write Endpoint for Ory Keto authorization ("Admin" API) |
| Postgres DB | 4455 (✗) | Postgres database for storing user data |

View file

@ -1,2 +1,2 @@
# The URL of ORY Hydras admin API
# The URL of Ory Hydras admin API
HYDRA_ADMIN_API=http://hydra:4445

View file

@ -70,6 +70,39 @@ services:
networks:
- internal
ory-keto-migrate:
container_name: ory-keto-migrate
image: oryd/keto:v0.12.0
restart: on-failure
volumes:
- ./ory/keto:/etc/config/keto
- ory-keto-data:/home/ory
- ory-keto-data:/var/lib/sqlite
command: migrate -c /etc/config/keto/keto.yaml up --yes
depends_on:
ory-postgres:
condition: service_healthy
networks:
- internal
ory-keto:
container_name: ory-keto
image: oryd/keto:v0.12.0
restart: unless-stopped
ports:
- 127.0.0.1:4466:4466 # public
- 127.0.0.1:4467:4467 # admin
volumes:
- ./ory/keto:/etc/config/keto
- ory-keto-data:/home/ory
- ory-keto-data:/var/lib/sqlite
command: serve -c /etc/config/keto/keto.yaml all
depends_on:
ory-keto-migrate:
condition: service_completed_successfully
networks:
- internal
ory-mailslurper:
container_name: ory-mailslurper
@ -111,4 +144,5 @@ networks:
volumes:
ory-kratos-data:
ory-hydra-data:
ory-keto-data:
ory-postgres-data:

View file

@ -0,0 +1,24 @@
# this script gives the referenced identity the admin role
# make sure to provide the id of the identity
# check if a identity id argument was provided
if [ -z "$1" ]; then
echo "Error: please provide an identity id."
exit 1
fi
# set user id variable
IDENTITY_ID=$1
# execute curl to Ory Keto write endpoint
curl --request PUT \
--url http://localhost:4467/admin/relation-tuples \
--data '{
"namespace": "roles",
"object": "admin",
"relation": "member",
"subject_id": "'"$IDENTITY_ID"'"
}'
# write success response to terminal
echo "Applied admin role to the user with ID $IDENTITY_ID"

View file

@ -0,0 +1,43 @@
#
# Documentation: https://www.ory.sh/docs/keto/reference/configuration
# Configuration UI: https://www.ory.sh/docs/keto/reference/configuration-editor
#
#
# Configure the Keto logging
#
log:
level: info
format: text
leak_sensitive_values: true
#
# Configure the datasource. Alternative for development purposes is 'memory' (not persistent!)
#
dsn: postgres://postgres:postgres@ory-postgres:5432/keto?sslmode=disable&max_conns=20&max_idle_conns=4
#
# Set the required namespaces
#
namespaces:
- id: 0
name: roles
serve:
read:
host: 0.0.0.0
port: 4466
cors:
enabled: true
allowed_origins:
- http://localhost:3000
- http://localhost:4000
write:
host: 0.0.0.0
port: 4467
cors:
enabled: true
allowed_origins:
- http://localhost:3000
- http://localhost:4000

View file

@ -15,9 +15,6 @@
"credentials": {
"password": {
"identifier": true
},
"webauthn": {
"identifier": true
}
},
"recovery": {

View file

@ -3,3 +3,6 @@ GRANT ALL PRIVILEGES ON DATABASE kratos TO postgres;
CREATE DATABASE hydra;
GRANT ALL PRIVILEGES ON DATABASE hydra TO postgres;
CREATE DATABASE keto;
GRANT ALL PRIVILEGES ON DATABASE keto TO postgres;