Merge branch 'simkir/codex' into 'main'

Codex updates

See merge request oceanbox/Poseidon!142
This commit was merged in pull request #245.
This commit is contained in:
2026-01-19 14:39:35 +01:00
26 changed files with 1225 additions and 489 deletions

View File

@@ -23,6 +23,9 @@ max_line_length= 80
indent_size = 2
max_line_length = 80
[*.yaml]
indent_size = 2
[*.fs]
max_line_length= 120

View File

@@ -1,6 +1,6 @@
{
"sdk": {
"version": "10.0.0",
"version": "10.0.100",
"rollForward": "latestMinor"
}
}

View File

@@ -1,33 +1,35 @@
module Main
open System
open System.Text.Json
open System.Text.Json.Serialization
open System.Net.Http
open Microsoft.AspNetCore.Authentication
open Microsoft.AspNetCore.Builder
open Microsoft.AspNetCore.Cors.Infrastructure
open Microsoft.AspNetCore.Hosting
open Microsoft.AspNetCore.Http
open Microsoft.AspNetCore.SignalR
open Microsoft.Extensions.DependencyInjection
open Microsoft.Extensions.FileProviders
open Microsoft.Extensions.Hosting
open Microsoft.Extensions.Logging
open Argu
open Dapr.Actors
open Dapr.Actors.Client
open Dapr.Client
open FSharp.Data
open FsToolkit.ErrorHandling
open Fable.SignalR
open Giraffe
open Microsoft.AspNetCore.Authentication
open Microsoft.AspNetCore.Builder
open Microsoft.AspNetCore.Hosting
open Microsoft.AspNetCore.Cors.Infrastructure
open Microsoft.AspNetCore.Http
open Microsoft.AspNetCore.SignalR
open Microsoft.Extensions.DependencyInjection
open Microsoft.Extensions.Logging
open Microsoft.Extensions.FileProviders
open Microsoft.Extensions.Hosting
open System.Net.Http
open Oceanbox.DataAgent
open Polly
open Prometheus
open Saturn
open Saturn.Dapr
open Saturn.OpenTelemetry
open Saturn.Observer
open Saturn.OpenTelemetry
open Sentry
open Sentry.AspNetCore
open Sentry.Extensibility
@@ -37,6 +39,7 @@ open Serilog.Sinks.OpenTelemetry
open Atlantis
open Atlantis.Shared
open Oceanbox.DataAgent
open Oceanbox.ServerPack.MultiAuth
open Saturn.OpenFga
open Settings
@@ -82,7 +85,8 @@ let configureSerilog () =
let configureLogging (builder: ILoggingBuilder) =
builder
.ClearProviders()
.AddSerilog() |> ignore
.AddSerilog()
|> ignore
let corsPolicy (policy: CorsPolicyBuilder) =
policy
@@ -91,7 +95,7 @@ let corsPolicy (policy: CorsPolicyBuilder) =
.AllowAnyMethod()
.WithOrigins(appsettings.file.allowedOrigins)
.SetIsOriginAllowedToAllowWildcardSubdomains()
|> ignore
|> ignore
type UserIdProvider() =
interface IUserIdProvider with
@@ -254,12 +258,12 @@ let stopImpersonating (next: HttpFunc) (ctx: HttpContext) =
let getBarentsWatchToken (next: HttpFunc) (ctx: HttpContext) =
task {
let! tokenRes =
tryGetEnv "BARENTSWATCH_CLIENT_ID"
|> Option.bind (fun id ->
tryGetEnv "BARENTSWATCH_SECRET"
|> Option.map (BarentsWatch.getToken appsettings.redis id))
|> Option.defaultValue (Error "Secret or client id missing" |> async.Return)
|> Async.StartAsTask
taskResult {
let! id = tryGetEnv "BARENTSWATCH_CLIENT_ID" |> Result.requireSome "Missing barentswatch client id"
let! secret = tryGetEnv "BARENTSWATCH_SECRET" |> Result.requireSome "Missing barentswatch secret"
let! token = BarentsWatch.getToken appsettings.redis id secret
return token
}
match tokenRes with
| Error err ->

View File

@@ -63,6 +63,8 @@
"allowedOrigins": [
"http://*.oceanbox.io",
"https://*.oceanbox.io",
"https://*.oceanbox.io:8080",
"https://*.oceanbox.io:10380",
"https://*.vtn.obx",
"https://*.tox.obx",
],

View File

@@ -1,103 +1,103 @@
replicaCount: 1
image:
tag: latest
tag: latest
podAnnotations:
dapr.io/enabled: "true"
dapr.io/app-id: "<x>-atlantis"
dapr.io/app-port: "8085"
dapr.io/api-token-secret: "dapr-api-token"
dapr.io/config: "tracing"
dapr.io/app-protocol: "http"
dapr.io/log-as-json: "true"
dapr.io/sidecar-cpu-request: "10m"
dapr.io/sidecar-memory-request: "50Mi"
# dapr.io/sidecar-cpu-limit: "300m"
# dapr.io/sidecar-memory-limit: "1000Mi"
dapr.io/enabled: "true"
dapr.io/app-id: "<x>-atlantis"
dapr.io/app-port: "8085"
dapr.io/api-token-secret: "dapr-api-token"
dapr.io/config: "tracing"
dapr.io/app-protocol: "http"
dapr.io/log-as-json: "true"
dapr.io/sidecar-cpu-request: "10m"
dapr.io/sidecar-memory-request: "50Mi"
# dapr.io/sidecar-cpu-limit: "300m"
# dapr.io/sidecar-memory-limit: "1000Mi"
env:
- name: APP_NAMESPACE
value: <x>-atlantis
- name: APP_VERSION
value: "<x>-tilt"
- name: LOG_LEVEL
value: "verbose"
- name: REDIS_USER
value: default
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: <x>-atlantis-redis
key: redis-password
- name: DB_HOST
value: <x>-atlantis-db-rw
- name: DB_PORT
value: "5432"
- name: DB_USER
valueFrom:
secretKeyRef:
name: <x>-atlantis-db-app
key: username
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: <x>-atlantis-db-app
key: password
- name: DAPR_API_TOKEN
valueFrom:
secretKeyRef:
name: dapr-api-token
key: token
- name: ANALYTICS_WEB_ID
value: 6f26c702-2c6d-46ea-8122-ffcedda5f762
- name: APP_NAMESPACE
value: <x>-atlantis
- name: APP_VERSION
value: "<x>-tilt"
- name: LOG_LEVEL
value: "verbose"
- name: REDIS_USER
value: default
- name: REDIS_PASSWORD
valueFrom:
secretKeyRef:
name: <x>-atlantis-redis
key: redis-password
- name: DB_HOST
value: <x>-atlantis-db-rw
- name: DB_PORT
value: "5432"
- name: DB_USER
valueFrom:
secretKeyRef:
name: <x>-atlantis-db-app
key: username
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: <x>-atlantis-db-app
key: password
- name: DAPR_API_TOKEN
valueFrom:
secretKeyRef:
name: dapr-api-token
key: token
- name: ANALYTICS_WEB_ID
value: 6f26c702-2c6d-46ea-8122-ffcedda5f762
ingress:
enabled: true
annotations:
cert-manager.io/cluster-issuer: letsencrypt-staging
nginx.ingress.kubernetes.io/proxy-buffer-size: 128k
nginx.ingress.kubernetes.io/whitelist-source-range: 10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
hosts:
- host: <x>-atlantis.dev.oceanbox.io
paths:
- path: /
pathType: ImplementationSpecific
internal:
- path: /internal
pathType: ImplementationSpecific
- path: /dapr
pathType: ImplementationSpecific
- path: /actors
pathType: ImplementationSpecific
- path: /job
pathType: ImplementationSpecific
- path: /events
pathType: ImplementationSpecific
- path: /metrics
pathType: ImplementationSpecific
tls:
- hosts:
- <x>-atlantis.dev.oceanbox.io
secretName: <x>-atlantis-tls
enabled: true
annotations:
cert-manager.io/cluster-issuer: letsencrypt-staging
nginx.ingress.kubernetes.io/proxy-buffer-size: 128k
nginx.ingress.kubernetes.io/whitelist-source-range: 10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
hosts:
- host: <x>-maps.dev.oceanbox.io
paths:
- path: /
pathType: ImplementationSpecific
internal:
- path: /internal
pathType: ImplementationSpecific
- path: /dapr
pathType: ImplementationSpecific
- path: /actors
pathType: ImplementationSpecific
- path: /job
pathType: ImplementationSpecific
- path: /events
pathType: ImplementationSpecific
- path: /metrics
pathType: ImplementationSpecific
tls:
- hosts:
- <x>-maps.dev.oceanbox.io
secretName: <x>-atlantis-tls
storage:
enabled: true
size: 1G
accessMode: ReadWriteOnce
storageClass: ceph-rdb
enabled: true
size: 1G
accessMode: ReadWriteOnce
storageClass: ceph-rdb
securityContext:
capabilities:
drop:
- ALL
readOnlyRootFilesystem: false
runAsNonRoot: false
runAsUser: 0
capabilities:
drop:
- ALL
readOnlyRootFilesystem: false
runAsNonRoot: false
runAsUser: 0
cluster:
backup:
enabled: false
backup:
enabled: false
redis:
enabled: true

View File

@@ -97,7 +97,8 @@ type Archives =
| Ok archives ->
setArchives archives
setError None
| Error err -> setError (Some "Error fetching archives")
| Error err ->
setError (Some "Error fetching archives")
setLoading false
)
@@ -196,4 +197,4 @@ type Archives =
]
]
]
]
]

View File

@@ -5,6 +5,7 @@ module Utils =
open Fable.Core
open Fable.Core.JsInterop
open Fable.Remoting.Client
open FsToolkit.ErrorHandling
open Oceanbox.Codex
open Oceanbox.Codex.Types
@@ -71,3 +72,52 @@ module Utils =
return Error "Error fetching archive count"
}
let private fetchArchmaesterArchives (group: string) : JS.Promise<Result<Archmaester.Dto.ArchiveProps array, string>> =
promise {
let filter : Archmaester.Dto.ArchiveFilter = {
id = None
searchTerm = None
archiveType = None
owner = None
user = None
groups = Some [| group |]
}
let! result = Remoting.adminApi.getArchives 0 -1 filter |> Async.StartAsPromise
return result
}
let fetchGroupArchiveProps (group: string) : JS.Promise<Types.Archive array> =
promise {
let fgaUser = sprintf "group:%s#member" (Groups.Utils.canonicalizeName group)
let! archivesRes = fetchArchmaesterArchives group
let! viewsRes =
OpenFGA.fetchObjects(fgaUser, "view", "archive", context = {| time = System.DateTime.Now |})
let! execsRes =
OpenFGA.fetchObjects(fgaUser, "exec", "archive", context = {| task = "*"; usage = -1; time = System.DateTime.Now |})
let res =
result {
// TODO: Create specific exception
let! props = archivesRes |> Result.mapError System.Exception
let! views = viewsRes |> Result.mapError System.Exception
let! execs = execsRes |> Result.mapError System.Exception
let viewArchiveIds = views |> Array.choose extractFgaArchiveId
let execArchiveIds = execs |> Array.choose extractFgaArchiveId
let archives =
props
|> Array.map (fun prop -> {
Props = prop
CanView = viewArchiveIds |> Array.contains prop.archiveId
CanExec = execArchiveIds |> Array.contains prop.archiveId
})
return archives
}
match res with
| Ok archives ->
return archives
| Error ex ->
raise ex
return [||]
}

View File

@@ -9,6 +9,7 @@
<ItemGroup>
<Compile Include="../Shared/Remoting.fs" />
<Compile Include="Types.fs" />
<Compile Include="Utils.fs" />
<Compile Include="Extensions.fs" />
<Compile Include="Remoting.fs" />
<Compile Include="Map.fs" />
@@ -18,6 +19,8 @@
<Compile Include="OpenFGA/useReadTuples.fs" />
<Compile Include="OpenFGA/Checkbox.fs" />
<Compile Include="OpenFGA/ArchiveOwnerList.fs" />
<Compile Include="Users/DeleteForm.fs" />
<Compile Include="Users/OpenFgaList.fs" />
<Compile Include="Groups/Utils.fs" />
<Compile Include="Groups/useGroups.fs" />
<Compile Include="Groups/List.fs" />
@@ -34,6 +37,7 @@
<Compile Include="Groups.fs" />
<Compile Include="GroupArchiveAddForm.fs" />
<Compile Include="GroupArchive.fs" />
<Compile Include="GroupUser.fs" />
<Compile Include="Group.fs" />
<Compile Include="ArchivesList.fs" />
<Compile Include="Archives.fs" />

View File

@@ -30,7 +30,7 @@ type private NavTab =
| _ -> Index
member this.ToPath () =
[this.ToString ()] |> Router.Router.format
[this.ToString ()] |> Router.format
static member FromPath path =
match path with
@@ -155,7 +155,7 @@ type Components =
| [ "groups" ] -> Groups.View ()
| [ "groups"; group ] -> Group.View group
| [ "groups"; group; "archives"; Route.Guid id ] -> GroupArchive.View group id
| [ "groups"; group; "users"; user ] -> User.View user
| [ "groups"; group; "users"; user ] -> GroupUser.View group user
| [ "users"; user ] -> User.View user
| [ "organizations" ] -> Organizations.List ()
| [ "organizations"; org ] -> Organization.View org

View File

@@ -3,7 +3,6 @@ namespace Oceanbox.Codex
module Group =
open Browser
open Fable.Core
open FsToolkit.ErrorHandling
open Oceanbox.Codex.Types
@@ -17,59 +16,9 @@ module Group =
return Error (sprintf "Error fetching users for group %s" group)
}
let private fetchArchmaesterArchives (group: string) : JS.Promise<Result<Archmaester.Dto.ArchiveProps array, string>> =
promise {
let filter : Archmaester.Dto.ArchiveFilter = {
id = None
searchTerm = None
archiveType = None
owner = None
user = None
groups = Some [| group |]
}
let! result = Remoting.adminApi.getArchives 0 -1 filter |> Async.StartAsPromise
return result
}
module private Elmish =
open Elmish
let fetchArchiveProps (group: string) : JS.Promise<Types.Archive array> =
promise {
let fgaUser = sprintf "group:%s#member" (Groups.Utils.canonicalizeName group)
let! archivesRes = fetchArchmaesterArchives group
let! viewsRes =
OpenFGA.fetchObjects(fgaUser, "view", "archive", context = {| time = System.DateTime.Now |})
let! execsRes =
OpenFGA.fetchObjects(fgaUser, "exec", "archive", context = {| task = "*"; usage = -1; time = System.DateTime.Now |})
let res =
result {
// TODO: Create specific exception
let! props = archivesRes |> Result.mapError System.Exception
let! views = viewsRes |> Result.mapError System.Exception
let! execs = execsRes |> Result.mapError System.Exception
let viewArchiveIds = views |> Array.choose Archives.Utils.extractFgaArchiveId
let execArchiveIds = execs |> Array.choose Archives.Utils.extractFgaArchiveId
let archives =
props
|> Array.map (fun prop -> {
Props = prop
CanView = viewArchiveIds |> Array.contains prop.archiveId
CanExec = execArchiveIds |> Array.contains prop.archiveId
})
return archives
}
match res with
| Ok archives ->
return archives
| Error ex ->
raise ex
return [||]
}
type Msg =
| FetchArchives of string
| SetArchiveAdding of bool
@@ -97,7 +46,7 @@ module Group =
let update msg model =
match msg with
| FetchArchives group ->
model, Cmd.OfPromise.either fetchArchiveProps group SetArchives HandleExn
model, Cmd.OfPromise.either Archives.Utils.fetchGroupArchiveProps group SetArchives HandleExn
| HandleExn ex ->
let msg =
match ex with
@@ -470,4 +419,4 @@ module Group =
]
]
]
]
]

View File

@@ -1,10 +1,16 @@
namespace Oceanbox.Codex
type private Permission = {
Tuple: Remoting.Tuple
Relation: Remoting.ArchiveRelation
}
module GroupArchive =
open Browser
open Fable.Core
open Feliz
open Feliz.Router
open FS.FluentUI
let private postPermissions (group: string) (archiveId: System.Guid) (permissions: Remoting.ArchiveRelation array) =
promise {
@@ -18,6 +24,18 @@ module GroupArchive =
return res
}
let private putPermissions (group: string) (archiveId: System.Guid) (permissions: Remoting.ArchiveRelation array) =
promise {
console.debug("Updating existing relations: %o", permissions)
let req : Remoting.AddGroupPermissionsRequest = {
Group = Groups.Utils.canonicalizeName group
ArchiveId = archiveId
Permissions = permissions
}
let! res = Remoting.adminApi.updateGroupPermissions req |> Async.StartAsPromise
return res
}
[<ReactComponent>]
let private DeleteRelationButton onDelete (tuple: Remoting.Tuple) =
let handleDelete (ev: Types.Event) =
@@ -36,64 +54,133 @@ module GroupArchive =
console.error ("[Group] Error deleting tuple: %s\n%o", err, tuple)
)
Html.button [ prop.onClick handleDelete; prop.text "Delete" ]
Fui.button [
button.onClick handleDelete
button.icon (Fui.icon.deleteRegular [])
]
[<ReactComponent>]
let private ViewTerm
(onDelete: Remoting.Tuple -> unit)
(viewTerm: Remoting.ViewTerm)
(tuple: Remoting.Tuple)
=
let private PermissionCard (title: string) onDelete (tuple: Remoting.Tuple) (children: ReactElement array) =
Html.div [
prop.classes [ "flex-column"; "gap-8"; "shadow"; "brad-8"; "m-8"; "p-16" ]
prop.style [ style.flexBasis (length.px 320) ]
prop.style [ style.flexBasis (length.px 384) ]
prop.children [
Html.div [
prop.classes [ "flex-row-center" ]
prop.children [
Html.div [
prop.classes [ "grow" ]
prop.children [ Html.b "View Term" ]
prop.children [
Html.b [
prop.style [
style.fontSize(length.px 16)
]
prop.text title
]
]
]
DeleteRelationButton onDelete tuple
]
]
Html.div [
prop.classes [ "ml-16" ]
prop.children [
Html.div (sprintf "Start time: %s" (Intl.shortDateTime viewTerm.StartTime))
Html.div (sprintf "End time: %s" (Intl.shortDateTime viewTerm.EndTime))
]
prop.children children
]
]
]
[<ReactComponent>]
let private ExecTicket onDelete (ticket: Remoting.ExecTicket) (tuple: Remoting.Tuple) =
Html.div [
prop.classes [ "flex-column"; "gap-8"; "shadow"; "brad-8"; "m-8"; "p-16" ]
prop.style [ style.flexBasis (length.px 320) ]
prop.children [
Html.div [
prop.classes [ "flex-row-center" ]
prop.children [
Html.div [ prop.classes [ "grow" ]; prop.children [ Html.b "Exec Ticket" ] ]
let private ViewTerm
key
(onUpdate: Permission -> unit)
(permission: Permission)
(viewTerm: Remoting.ViewTerm)
=
let updateCond (newCond: Remoting.ViewTerm) =
let updatedCond =
permission.Tuple.Condition
|> Option.map (fun cond ->
{ cond with Context = JS.JSON.stringify newCond.JsonObj }
)
onUpdate {
permission with
Tuple.Condition = updatedCond
Relation = Remoting.ArchiveRelation.ViewTerm newCond
}
DeleteRelationButton onDelete tuple
]
]
Html.div [
prop.classes [ "ml-16" ]
prop.children [
Html.div (sprintf "Start time: %s" (Intl.shortDateTime ticket.StartTime))
Html.div (sprintf "End time: %s" (Intl.shortDateTime ticket.EndTime))
Html.div (sprintf "Quota: %.1f" ticket.Quota)
Html.div [
prop.children [
Html.span "Tasks:"
Html.ul [
prop.children (ticket.Tasks |> Array.map (fun task -> Html.li task))
let handleStartChange =
React.useCallback (
(fun (newStartOpt: System.DateTime option) ->
match newStartOpt with
| Some newStart ->
let updated = { viewTerm with StartTime = newStart }
updateCond updated
| None ->
console.error("Got no date from date picker")
),
[| permission |]
)
let handleEndChange =
React.useCallback (
(fun (newEndOpt: System.DateTime option) ->
match newEndOpt with
| Some newEnd ->
let updated = { viewTerm with EndTime = newEnd }
updateCond updated
| None ->
console.error("Got no date from date picker")
),
[| permission |]
)
Fui.table [
table.size.medium
table.children [
Fui.tableBody [
tableBody.children [
Fui.tableRow [
tableRow.key "view-term-start-time"
tableRow.children [
Fui.tableCell [
tableCell.text "Start time"
]
Fui.tableCell [
tableCell.children (
Fui.tableCellLayout [
tableCellLayout.children (
Fui.datePicker [
datePicker.size.small
datePicker.onSelectDate handleStartChange
datePicker.value (Some viewTerm.StartTime)
]
)
]
)
]
]
]
Fui.tableRow [
tableRow.key "view-term-end-time"
tableRow.children [
Fui.tableCell [
tableCell.text "End time"
]
Fui.tableCell [
tableCell.children (
Fui.tableCellLayout [
tableCellLayout.children (
Fui.datePicker [
datePicker.size.small
datePicker.onSelectDate handleEndChange
datePicker.value (Some viewTerm.EndTime)
]
)
]
)
]
]
]
@@ -102,88 +189,279 @@ module GroupArchive =
]
]
[<ReactComponent>]
let private ExecTicket
(key: string)
(onUpdate: Permission -> unit)
(permission: Permission)
(ticket: Remoting.ExecTicket)
=
let updateCond (newCond: Remoting.ExecTicket) =
let updatedCond =
permission.Tuple.Condition
|> Option.map (fun cond ->
{ cond with Context = JS.JSON.stringify newCond.JsonObj }
)
onUpdate {
permission with
Tuple.Condition = updatedCond
Relation = Remoting.ArchiveRelation.ExecTicket newCond
}
let handleStartChange =
React.useCallback (
(fun (newStartOpt: System.DateTime option) ->
match newStartOpt with
| Some newStart ->
let updated = { ticket with StartTime = newStart }
updateCond updated
| None ->
console.error("Got no date from date picker")
),
[| ticket |]
)
let handleEndChange =
React.useCallback (
(fun (newEndOpt: System.DateTime option) ->
match newEndOpt with
| Some newEnd ->
let updated = { ticket with EndTime = newEnd }
updateCond updated
| None ->
console.error("Got no date from date picker")
),
[| ticket |]
)
Fui.table [
table.size.medium
table.children [
Fui.tableBody [
tableBody.children [
Fui.tableRow [
tableRow.key "exec-ticket-start-time"
tableRow.children [
Fui.tableCell [
tableCell.text "Start time"
]
Fui.tableCell [
tableCell.children (
Fui.tableCellLayout [
tableCellLayout.children (
Fui.datePicker [
datePicker.size.small
datePicker.onSelectDate handleStartChange
datePicker.value (Some ticket.StartTime)
]
)
]
)
]
]
]
Fui.tableRow [
tableRow.key "exec-ticket-end-time"
tableRow.children [
Fui.tableCell [
tableCell.text "End time"
]
Fui.tableCell [
tableCell.children (
Fui.tableCellLayout [
tableCellLayout.children (
Fui.datePicker [
datePicker.size.small
datePicker.onSelectDate handleEndChange
datePicker.value (Some ticket.EndTime)
]
)
]
)
]
]
]
Fui.tableRow [
tableRow.key "exec-ticket-quota"
tableRow.children [
Fui.tableCell [
tableCell.text "Quota"
]
Fui.tableCell [
tableCell.children (
Fui.tableCellLayout [
tableCellLayout.children (
Fui.input [
input.size.small
input.type'.number
input.value ticket.Quota
]
)
]
)
]
]
]
Fui.tableRow [
tableRow.key "exec-ticket-tasks"
tableRow.children [
Fui.tableCell [
tableCell.text "Tasks"
]
Fui.tableCell [
tableCell.children (
Fui.tableCellLayout [
tableCellLayout.children (
Fui.text (
ticket.Tasks
|> String.concat ", "
)
)
]
)
]
]
]
]
]
]
]
[<ReactComponent>]
let private PermissionCreateCard group archiveId (onCreate: Permission -> unit) (defaultPermission: Permission) =
let isLoading, setLoading = React.useState false
let permission, setPermission = React.useState defaultPermission
let handleCreate (ev: Types.Event) =
setLoading true
postPermissions group archiveId [| permission.Relation |]
|> Promise.iter (fun res ->
match res with
| Ok () ->
console.info("Success.")
onCreate permission
| Error msg ->
console.error("Error adding permissions %s.", msg)
setLoading false
)
let handleUpdateRelation (permission: Permission) =
setPermission permission
console.debug("Permission: %o", permission)
Html.div [
prop.classes [ "flex-column"; "gap-8"; "shadow"; "brad-8"; "m-8"; "p-16" ]
prop.style [ style.flexBasis (length.px 384) ]
prop.children [
Html.div [
prop.classes [ "flex-row-center" ]
prop.children [
Html.div [
prop.classes [ "grow" ]
prop.children [
Html.b [
prop.style [
style.fontSize(length.px 16)
]
prop.text (
match permission.Relation with
| Remoting.ArchiveRelation.ViewTerm _ -> "View Term"
| Remoting.ArchiveRelation.ExecTicket _ -> "Exec Ticket"
)
]
]
]
Fui.button [
button.onClick handleCreate
button.icon (
if isLoading then
Fui.spinner [ spinner.size.tiny ]
else
Fui.icon.addRegular []
)
]
]
]
Html.div [
prop.children [|
match permission.Relation with
| Remoting.ArchiveRelation.ViewTerm term ->
ViewTerm
"create-permission-view-term"
handleUpdateRelation
permission
term
| Remoting.ArchiveRelation.ExecTicket ticket ->
ExecTicket
"create-permission-exec-ticket"
handleUpdateRelation
permission
ticket
|]
]
]
]
let private allRelations = [|
Remoting.ArchiveRelation.ViewTerm Remoting.ViewTerm.empty
Remoting.ArchiveRelation.ExecTicket Remoting.ExecTicket.empty
|]
[<ReactComponent>]
let private PermissionForm
(group: string)
(archiveId: System.Guid)
(onUpdate: Remoting.Tuple array -> unit)
(tuples: Remoting.Tuple array)
(onAdd: Permission -> unit)
(permissions: Permission array)
=
let adding, setAdding = React.useState false
let loading, setLoading = React.useState false
let success, setSuccess = React.useState false
let newView, setNewView = React.useState<Remoting.ViewTerm option> None
let newExec, setNewExec = React.useState<Remoting.ExecTicket option> None
let newPermissions = [|
match newView with
| Some view -> Remoting.ArchiveRelation.ViewTerm view
| None -> ()
// Create a list of permissions missing from the archive
let availablePermissions : Permission array =
allRelations
|> Array.choose (fun relation ->
let exists =
permissions
|> Array.exists (fun permission ->
let name = Remoting.ArchiveRelation.ConditionName relation
permission.Tuple.Condition
|> Option.map (fun cond -> cond.Name = name)
|> Option.defaultValue false
)
match newExec with
| Some exec -> Remoting.ArchiveRelation.ExecTicket exec
| None -> ()
|]
// TODO: Go back to using .Is* when we can
let hasViewTerm =
tuples
|> Array.exists (fun tuple ->
match tuple.Relation with
| "view" -> true
| _ -> false
)
let hasExecTicket =
tuples
|> Array.exists (fun tuple ->
match tuple.Relation with
| "exec" -> true
| _ -> false
if exists then
None
else
let user = Groups.Utils.fgaMember group
let object = sprintf "archive:%O" archiveId
Some {
Tuple = OpenFGA.Types.ArchiveRelation.toTuple user object relation
Relation = relation
}
)
let handleAddClick (ev: Types.Event) =
if not hasViewTerm && newView.IsNone then
setNewView (Some Remoting.ViewTerm.empty)
if not hasExecTicket && newExec.IsNone then
setNewExec (Some Remoting.ExecTicket.empty)
setAdding true
let handleSaveClick =
React.useCallback (
(fun (ev: Types.Event) ->
setLoading true
postPermissions group archiveId newPermissions
|> Promise.iter (fun res ->
match res with
| Ok () ->
console.info("Success.")
setAdding false
setSuccess true
setNewView None
setNewExec None
let user = Groups.Utils.fgaMember group
let object = sprintf "archive:%O" archiveId
newPermissions
|> Array.map (OpenFGA.Types.ArchiveRelation.toTuple user object)
|> Array.append tuples
|> onUpdate
| Error msg ->
console.error("Error adding permissions %s.", msg)
setLoading false
)
),
[| newPermissions |]
)
let handleCancelClick (ev: Types.Event) =
setAdding false
let handleUpdateView (updated) =
setNewView (Some updated)
let handleUpdateExec (updated) =
setNewExec (Some updated)
let handlePermissionAdd (newPermission: Permission) =
console.debug("Added new permission: %o", newPermission)
onAdd newPermission
React.fragment [
Html.div [
@@ -196,18 +474,13 @@ module GroupArchive =
Html.p "Loading ..."
else
if adding then
Html.button [
prop.onClick handleSaveClick
prop.text "Save"
]
Html.button [
prop.onClick handleCancelClick
prop.text "Cancel"
]
else
Html.button [
prop.disabled (hasViewTerm && hasExecTicket)
prop.disabled (Array.isEmpty availablePermissions)
prop.onClick handleAddClick
prop.text "Add"
]
@@ -225,46 +498,11 @@ module GroupArchive =
"gap-32"
]
prop.children [
if not hasViewTerm then
match newView with
| Some view ->
Html.div [
prop.classes [
"flex-column"
"gap-8"
"shadow"
"brad-8"
"m-8"
"p-16"
]
prop.children [
Html.b "View"
Groups.ViewForm (view, handleUpdateView)
]
]
| None -> ()
if not hasExecTicket then
match newExec with
| Some exec ->
Html.div [
prop.classes [
"flex-column"
"gap-8"
"shadow"
"brad-8"
"m-8"
"p-16"
]
prop.style [
style.flexBasis (length.px 512)
]
prop.children [
Html.b "Exec"
Groups.ExecForm (exec, handleUpdateExec)
]
]
| None -> ()
availablePermissions
|> Array.map (fun permission ->
PermissionCreateCard group archiveId handlePermissionAdd permission
)
|> unbox
]
]
]
@@ -273,15 +511,24 @@ module GroupArchive =
let View (group: string) (archiveId: System.Guid) =
let loading, setLoading = React.useState true
let error, setError = React.useState<string option> None
let archiveOpt, setArchive =
React.useState<Archmaester.Dto.ArchiveProps option> None
let archiveOpt, setArchive = React.useState<Archmaester.Dto.ArchiveProps option> None
let fgaUser = Groups.Utils.fgaMember group
let tuples = OpenFGA.useReadTuples (fgaUser, object = sprintf "archive:%O" archiveId)
let relations: Remoting.ArchiveRelation array =
let permissions: Permission array =
tuples.Tuples
|> Array.choose (fun tuple -> tuple.Condition |> Option.bind OpenFGA.Types.ArchiveRelation.tryOfCondition)
|> Array.choose (fun tuple ->
tuple.Condition
|> Option.bind (fun cond ->
cond
|> OpenFGA.Types.ArchiveRelation.tryOfCondition
|> Option.map (fun rel -> {
Tuple = tuple
Relation = rel
})
)
)
let handlePermissionDelete (tuple: Remoting.Tuple) =
console.debug("Deleting %o from %o", tuple, tuples)
@@ -289,6 +536,29 @@ module GroupArchive =
|> Array.filter (fun existing -> existing.Relation <> tuple.Relation)
|> tuples.SetTuples
let handlePermissionUpdate (updated: Permission) =
console.debug("Updated permission tuple %o", updated)
putPermissions group archiveId [| updated.Relation |]
|> Promise.iter (fun res ->
match res with
| Ok () ->
tuples.Tuples
|> Array.map (fun existing ->
let equal =
existing.Object = updated.Tuple.Object
&& existing.Relation = updated.Tuple.Relation
&& existing.User = updated.Tuple.User
if equal then
updated.Tuple
else
existing
)
|> tuples.SetTuples
| Error msg ->
setError (Some msg)
)
let handleUpdateRelations =
React.useCallback (
(fun (updated: Remoting.Tuple array) ->
@@ -299,6 +569,19 @@ module GroupArchive =
[| box tuples |]
)
let handleAddPermission =
React.useCallback (
(fun (newPermission: Permission) ->
console.debug("New relation added: %o with current: %o", newPermission, tuples.Tuples)
newPermission.Tuple
|> Array.singleton
|> Array.append tuples.Tuples
|> tuples.SetTuples
),
[| box tuples |]
)
React.useEffect (
(fun () ->
setLoading true
@@ -359,7 +642,7 @@ module GroupArchive =
else
Html.div [
prop.children [
PermissionForm group archive.archiveId handleUpdateRelations tuples.Tuples
PermissionForm group archive.archiveId handleAddPermission permissions
]
]
@@ -370,26 +653,23 @@ module GroupArchive =
]
]
if Array.isEmpty relations then
if Array.isEmpty permissions then
Html.p "No permissions"
else
Html.div [
prop.classes [ "flex-row-start"; "gap-32" ]
prop.children (
tuples.Tuples
|> Array.choose (fun tuple ->
tuple.Condition
|> Option.bind (fun cond ->
cond
|> OpenFGA.Types.ArchiveRelation.tryOfCondition
|> Option.map (fun rel ->
match rel with
| Remoting.ArchiveRelation.ViewTerm term ->
ViewTerm handlePermissionDelete term tuple
| Remoting.ArchiveRelation.ExecTicket ticket ->
ExecTicket handlePermissionDelete ticket tuple
)
)
permissions
|> Array.map (fun permission ->
match permission.Relation with
| Remoting.ArchiveRelation.ViewTerm term ->
PermissionCard "View Term" handlePermissionDelete permission.Tuple [|
ViewTerm "view-term-table" handlePermissionUpdate permission term
|]
| Remoting.ArchiveRelation.ExecTicket ticket ->
PermissionCard "Exec Ticket" handlePermissionDelete permission.Tuple [|
ExecTicket "exec-ticket-table" handlePermissionUpdate permission ticket
|]
)
)
]

View File

@@ -0,0 +1,204 @@
namespace Oceanbox.Codex
open Feliz
open Feliz.Router
module GroupUser =
[<ReactComponent>]
let private ArchiveList key (group: string) (title: string) (archives: Types.Archive array) =
Html.div [
prop.style [
style.flexBasis (length.px 256)
]
prop.children [
Html.h3 title
Html.ul [
prop.children (
archives
|> Array.sortBy _.Props.name
|> Array.map (fun archive ->
let text = Archives.Utils.archiveName archive
Html.li [
prop.children [
Html.a [
prop.href (Router.format("groups", group, "archives", string archive.Props.archiveId))
prop.text text
]
]
]
)
)
]
]
]
[<ReactComponent>]
let private ArchiveLists (group: string) =
let archives, setArchives = React.useState<Types.Archive array> [||]
React.useEffect (
(fun () ->
Archives.Utils.fetchGroupArchiveProps group
|> Promise.iter (fun res ->
setArchives res
)
),
[| box group |]
)
let fvcom =
archives
|> Array.filter (fun archive ->
match archive.Props.archiveType with
| Archmaester.Dto.Fvcom _ -> true
| _ -> false
)
let drifters =
archives
|> Array.filter (fun archive ->
match archive.Props.archiveType with
| Archmaester.Dto.Drifters _ -> true
| _ -> false
)
Html.div [
prop.classes ["flex-row-start"; "gap-8"]
prop.children [
ArchiveList "fvcom-ul-list" group "FVCOM" fvcom
ArchiveList "drifters-ul-list" group "Drifters" drifters
]
]
[<ReactComponent>]
let View (group: string) (user: string) =
let fgaUser = sprintf "user:%s" user
let execCtx = {|
time = System.DateTime.Now
task = "*"
usage = "-1"
|}
Html.main [
Html.h1 [
prop.children [
Html.text "Group "
Html.a [
prop.href (Router.format ("groups", group))
prop.text group
]
Html.text " / "
Html.text "User "
Html.a [
prop.href (Router.format ("users", user))
prop.text user
]
]
]
Html.section [
prop.children [
Users.DeleteForm user
]
]
Html.section [
prop.children [
Html.h2 "Archmaester"
Html.p "TODO"
]
]
Html.section [
prop.children [
Html.h2 "OpenFGA"
Html.div [
prop.classes [ "flex-row"; "flex-wrap"; "gap-8" ]
prop.children [
OpenFGA.Checkbox("active-checkbox", "Active", fgaUser, "active", fgaUser)
OpenFGA.Checkbox("registered-checkbox", "Registered", fgaUser, "registered", fgaUser)
OpenFGA.Checkbox("disabled-checkbox", "Disabled", fgaUser, "disabled", fgaUser)
]
]
Html.div [
prop.classes [ "flex-row"; "flex-wrap"; "gap-32" ]
prop.children [
Html.div [
prop.classes [ "grow" ]
prop.style [
style.flexBasis (length.px 320)
style.maxWidth (length.px 512)
style.minWidth (length.px 320)
]
prop.children [
Html.h3 "Archives with Owner"
Html.div [
prop.style [
style.overflowY.auto
style.maxHeight (length.px 512)
]
prop.children [
Users.OpenFgaList(fgaUser, "owner", "archive")
]
]
]
]
Html.div [
prop.classes [ "grow" ]
prop.style [
style.flexBasis (length.px 320)
style.maxWidth (length.px 512)
style.minWidth (length.px 320)
]
prop.children [
Html.h3 "Archives with View"
Html.div [
prop.style [
style.overflowY.auto
style.maxHeight (length.px 512)
]
prop.children [
Users.OpenFgaList(fgaUser, "view", "archive", {| time = System.DateTime.Now |})
]
]
]
]
Html.div [
prop.classes [ "grow" ]
prop.style [
style.flexBasis (length.px 320)
style.maxWidth (length.px 512)
style.minWidth (length.px 320)
]
prop.children [
Html.h3 "Archives with exec"
Html.div [
prop.style [
style.overflowY.auto
style.maxHeight (length.px 512)
]
prop.children [
Users.OpenFgaList(fgaUser, "exec", "archive", execCtx)
]
]
]
]
]
]
]
]
Html.section [
prop.children [
Html.h2 "Archives via group"
ArchiveLists group
]
]
]

View File

@@ -3,20 +3,42 @@ namespace Oceanbox.Codex
open Browser
open Fable.Core
open Feliz
open FS.FluentUI
[<Erase>]
type OpenFGA =
[<ReactComponent>]
static member Checkbox(key, label: string, user: string, relation: string, object: string) =
let isLoading, setLoading = React.useState false
let isChecked, setChecked = React.useState false
let handleChange (ev: Types.Event) =
console.debug("[OpenFGA.Checkbox] Checkbox %s changed to %o", key, not isChecked)
console.debug("[OpenFGA.Checkbox] Checkbox %s for user %s rel %s changed to %o", key, user, relation, not isChecked)
// TODO: Write to OpenFGA
setChecked(not isChecked)
let newChecked = not isChecked
// setChecked
setLoading true
Remoting.adminApi.setUserPermissions {
User = user
Permissions = [|
{ Name = relation; Enabled = newChecked }
|]
}
|> Async.StartAsPromise
|> Promise.iter (fun res ->
match res with
| Ok () ->
console.debug("Success.")
setChecked newChecked
| Error err ->
console.error("Error: %s", err)
setLoading false
)
React.useEffect (
(fun () ->
setLoading true
Remoting.openFgaApi.Check {
User = user
Relation = relation
@@ -30,6 +52,8 @@ type OpenFGA =
setChecked hasRelation
| Error err ->
console.error("[OpenFGA.Checkbox] Error checking user %s has relation %s to %s", user, relation, object)
setLoading false
)
),
[| |]
@@ -38,17 +62,21 @@ type OpenFGA =
Html.div [
prop.classes [ "flex-row-center" ]
prop.children [
Html.input [
prop.id (sprintf "openfga-checkbox-%s" key)
prop.type'.checkbox
prop.onChange handleChange
prop.custom("checked", isChecked)
]
if isLoading then
Fui.spinner [
spinner.size.tiny
]
else
Html.input [
prop.id (sprintf "openfga-checkbox-%s" key)
prop.type'.checkbox
prop.onChange handleChange
prop.custom("checked", isChecked)
]
Html.label [
prop.htmlFor (sprintf "openfga-checkbox-%s" key)
prop.text label
]
]
]
]

View File

@@ -29,23 +29,34 @@ type OpenFGA =
[<Hook>]
static member useObjects(user: string, relation: string, objectType: string, ?context: obj) : Objects =
let objects, setObjects = React.useState<Objects> Objects.Empty
let isLoading, setLoading = React.useState true
let error, setError = React.useState<string option> None
let objects, setObjects = React.useState<string array> Array.empty
React.useEffect (
(fun () ->
setObjects { objects with Loading = true; Error = None }
setLoading true
setError None
OpenFGA.fetchObjects(user, relation, objectType, context)
|> Promise.iter (fun res ->
match res with
| Ok newObjects ->
setObjects { objects with Loading = false; Objects = newObjects }
setObjects newObjects
| Error err ->
console.error("[OpenFGA] Error loading user objects: %s", err)
setObjects { objects with Loading = false; Error = Some err }
setError (Some err)
setLoading false
)
),
[| box user |]
)
objects
let props = {
Loading = isLoading
Error = error
Objects = objects
}
props

View File

@@ -1,123 +1,9 @@
namespace Oceanbox.Codex
open Browser
open Fable.Core
open Fable.Remoting.Client
open Feliz
open Feliz.Router
[<Erase>]
type User =
[<ReactComponent>]
static member List(user: string, relation: string, objectType: string, ?context: obj) =
let objects = OpenFGA.useObjects(user, relation, objectType, context)
if objects.Loading then
Html.p "Loading ..."
else
if Array.isEmpty objects.Objects then
Html.p (sprintf "No objects with user %s relation %s of type %s" user relation objectType)
else
Html.ul [
prop.children (
objects.Objects
|> Array.sort
|> Array.map (fun object ->
let split = object.Split ':'
match split with
| [| objectType; id |] ->
Html.li [
prop.key id
prop.children [
Html.a [
prop.href (Router.format("archives", id))
prop.text id
]
]
]
| _ ->
Html.li [
prop.text "Invalid object format"
]
)
)
]
module User =
[<ReactComponent>]
let private DeleteForm (user: string) =
let deleted, setDeleted = React.useState<Result<unit, string> option> None
let deleting, setDeleting = React.useState false
let handleDelete =
React.useCallback (
(fun () ->
setDeleting true
console.info("[User] Deleting user %s", user)
Remoting.adminApi.removeUsers [| user |]
|> Async.StartAsPromise
|> Promise.catch (fun ex ->
match ex with
| :? ProxyRequestException as e ->
let proxyError : Types.ProxyError = JS.JSON.parse e.ResponseText |> unbox
let msg = proxyError.error.errorMsg
Error msg
| ex ->
Error ex.Message
)
|> Promise.iter (fun res ->
match res with
| Ok () ->
console.info("[User] Successfully deleted user %s", user)
setDeleted (Some (Ok ()))
| Error err ->
console.error("[User] Error deleting user %s: %s", user, err)
setDeleted (Some (Error err))
)
),
[| box user |]
)
React.fragment [
match deleted with
| Some (Ok ()) ->
Html.p "User successfully deleted."
Html.a [
prop.onClick (fun ev ->
ev.preventDefault ()
Router.navigateBack ()
)
prop.href (Router.format "")
prop.text "Back"
]
| Some (Error err) ->
Html.p (sprintf "Error deleting user: %s" err)
| None ->
if deleting then
Html.div [
prop.classes [ "flex-row-center"; "gap-8" ]
prop.children [
Html.button [
prop.onClick (fun _ -> handleDelete ())
prop.text "Are you sure?"
]
Html.button [
prop.onClick (fun _ -> setDeleting false)
prop.text "Cancel"
]
]
]
Html.p "This will delete the user from the databases. Not disable the user."
else
Html.button [
prop.onClick (fun _ -> setDeleting true)
prop.text "Delete"
]
]
[<ReactComponent>]
let View (user: string) =
let fgaUser = sprintf "user:%s" user
@@ -127,15 +13,49 @@ module User =
usage = "-1"
|}
let groups = OpenFGA.useObjects(fgaUser, "member", "group")
Html.main [
Html.h1 user
Html.section [
prop.children [
DeleteForm user
Users.DeleteForm user
]
]
Html.section [
prop.children [
Html.h2 "Groups"
Html.div [
prop.children [
Html.ul [
prop.children (
groups.Objects
|> Array.map (fun group ->
let split = group.Split ':'
match split with
| [| "group"; groupName |] ->
Html.li [
prop.children [
Html.a [
prop.href (Router.format("groups", groupName))
prop.text groupName
]
]
]
| _ ->
Html.none
)
)
]
]
]
]
]
Html.section [
prop.children [
Html.h2 "Archmaester"
@@ -175,7 +95,7 @@ module User =
style.maxHeight (length.px 512)
]
prop.children [
User.List(fgaUser, "owner", "archive")
Users.OpenFgaList(fgaUser, "owner", "archive")
]
]
]
@@ -197,7 +117,7 @@ module User =
style.maxHeight (length.px 512)
]
prop.children [
User.List(fgaUser, "view", "archive", {| time = System.DateTime.Now |})
Users.OpenFgaList(fgaUser, "view", "archive", {| time = System.DateTime.Now |})
]
]
]
@@ -219,7 +139,7 @@ module User =
style.maxHeight (length.px 512)
]
prop.children [
User.List(fgaUser, "exec", "archive", execCtx)
Users.OpenFgaList(fgaUser, "exec", "archive", execCtx)
]
]
]

View File

@@ -0,0 +1,81 @@
namespace Oceanbox.Codex
open Browser
open Fable.Core
open Fable.Remoting.Client
open Feliz
open Feliz.Router
module Users =
[<ReactComponent>]
let DeleteForm (user: string) =
let deleted, setDeleted = React.useState<Result<unit, string> option> None
let deleting, setDeleting = React.useState false
let handleDelete =
React.useCallback (
(fun () ->
setDeleting true
console.info("[User] Deleting user %s", user)
Remoting.adminApi.removeUsers [| user |]
|> Async.StartAsPromise
|> Promise.catch (fun ex ->
match ex with
| :? ProxyRequestException as e ->
let proxyError : Types.ProxyError = JS.JSON.parse e.ResponseText |> unbox
let msg = proxyError.error.errorMsg
Error msg
| ex ->
Error ex.Message
)
|> Promise.iter (fun res ->
match res with
| Ok () ->
console.info("[User] Successfully deleted user %s", user)
setDeleted (Some (Ok ()))
| Error err ->
console.error("[User] Error deleting user %s: %s", user, err)
setDeleted (Some (Error err))
)
),
[| box user |]
)
React.fragment [
match deleted with
| Some (Ok ()) ->
Html.p "User successfully deleted."
Html.a [
prop.onClick (fun ev ->
ev.preventDefault ()
Router.navigateBack ()
)
prop.href (Router.format "")
prop.text "Back"
]
| Some (Error err) ->
Html.p (sprintf "Error deleting user: %s" err)
| None ->
if deleting then
Html.div [
prop.classes [ "flex-row-center"; "gap-8" ]
prop.children [
Html.button [
prop.onClick (fun _ -> handleDelete ())
prop.text "Are you sure?"
]
Html.button [
prop.onClick (fun _ -> setDeleting false)
prop.text "Cancel"
]
]
]
Html.p "This will delete the user from the databases. Not disable the user."
else
Html.button [
prop.onClick (fun _ -> setDeleting true)
prop.text "Delete"
]
]

View File

@@ -0,0 +1,42 @@
namespace Oceanbox.Codex
open Fable.Core
open Feliz
open Feliz.Router
[<Erase>]
type Users =
[<ReactComponent>]
static member OpenFgaList(user: string, relation: string, objectType: string, ?context: obj) =
let objects = OpenFGA.useObjects(user, relation, objectType, context)
if objects.Loading then
Html.p "Loading ..."
else
if Array.isEmpty objects.Objects then
Html.p (sprintf "No objects with user %s relation %s of type %s" user relation objectType)
else
Html.ul [
prop.children (
objects.Objects
|> Array.sort
|> Array.map (fun object ->
let split = object.Split ':'
match split with
| [| objectType; id |] ->
Html.li [
prop.key id
prop.children [
Html.a [
prop.href (Router.format("archives", id))
prop.text id
]
]
]
| _ ->
Html.li [
prop.text "Invalid object format"
]
)
)
]

View File

@@ -0,0 +1,7 @@
namespace Oceanbox.Codex
module Utils =
open Fable.Core
open Feliz
let toReact (el: JSX.Element) : ReactElement = unbox el

View File

@@ -528,23 +528,101 @@ module Admin =
return ()
}
let setUserPermissions (ctx: HttpContext) (req: Remoting.UserPermissionRequest) =
async {
let user = ctx.User.Identity.Name
let logger = ctx.GetLogger<Remoting.Api.Admin> ()
do logger.LogInformation ("setUserPermissions from {User}: {@Req}", user, req)
// TODO(simkir): Sanitize/check the request, aka turn the dto to an internal type
try
let fga = ctx.GetService<OpenFgaClient> ()
let writes: Model.ClientWriteRequest =
req.Permissions
|> Array.choose (fun permission ->
if permission.Enabled then
Some {
Remoting.Tuple.empty with
User = req.User
Relation = permission.Name
Object = req.User
}
else
None
)
|> OpenFGA.Queries.write
if writes.Writes.Count > 0 then
let! fgaWriteResp = fga.Write writes |> Async.AwaitTask
do logger.LogInformation ("OpenFGA write responded with: {JSON}", fgaWriteResp.ToJson ())
let deletes =
req.Permissions
|> Array.choose (fun permission ->
if permission.Enabled then
None
else
Remoting.Tuple.delete(req.User, permission.Name, req.User)
|> Some
)
|> OpenFGA.Queries.deleteTuples
if deletes.Count > 0 then
let! fgaDeleteResp = fga.DeleteTuples deletes |> Async.AwaitTask
do logger.LogInformation ("OpenFGA delete responded with: {JSON}", fgaDeleteResp.ToJson ())
return Ok ()
with e ->
do logger.LogError (e, "Error setting user permissions")
// TODO: Maybe do not send exn message
return Error (sprintf "Error setting user permissions: %s" e.Message)
}
let updateGroupPermissions (ctx: HttpContext) (req: Remoting.AddGroupPermissionsRequest) =
async {
let user = ctx.User.Identity.Name
let logger = ctx.GetLogger<Remoting.Api.Admin> ()
do logger.LogInformation ("updateGroupPermissions from {User}: {@Req}", user, req)
try
let fga = ctx.GetService<OpenFgaClient> ()
let deletes =
req.Permissions
|> Array.map (permissionToTuple req.ArchiveId req.Group)
|> OpenFGA.Queries.deleteTuples'
let! deleteResp = fga.DeleteTuples deletes |> Async.AwaitTask
do logger.LogInformation ("OpenFGA delete responded with: {JSON}", deleteResp.ToJson ())
let writes =
req.Permissions
|> Array.map (permissionToTuple req.ArchiveId req.Group)
|> ResizeArray
let! writeResp = fga.WriteTuples writes |> Async.AwaitTask
do logger.LogInformation ("OpenFGA write responded with: {JSON}", writeResp.ToJson ())
return Ok ()
with e ->
do logger.LogError (e, "Error updating group permissions")
// TODO: Maybe do not send exn message
return Error (sprintf "Error updating group permissions: %s" e.Message)
}
let private impl (ctx: HttpContext) : Remoting.Api.Admin = {
addUsers = Handler.addUsers ctx
addArchive = Handler.addArchive ctx
addArchiveGroups = Handler.addArchiveGroups ctx
addGroupPermissions = Handler.addGroupPermissions ctx
addUsers = Handler.addUsers ctx
deleteArchive = Handler.deleteArchive ctx
getAllGroups = Handler.getAllGroups ctx
getArchive = Handler.getArchive ctx
getArchiveCount = Handler.getArchiveCount ctx
getArchiveDataSet = Handler.getArchiveDataSet ctx
getArchiveRefs = Handler.getArchiveRefs ctx
getArchiveTypes = fun () -> Handler.getArchiveTypes ctx
getArchives = Handler.getArchives ctx
getDataSets = fun () -> Handler.getAllDataSets ctx
getGroupUsers = Handler.getGroupUsers ctx
removeUsers = Handler.removeUsers ctx
getDataSets = fun () -> Handler.getAllDataSets ctx
getArchiveDataSet = Handler.getArchiveDataSet ctx
addArchive = Handler.addArchive ctx
setUserPermissions = Handler.setUserPermissions ctx
updateArchive = Handler.updateArchive ctx
updateGroupPermissions = Handler.updateGroupPermissions ctx
}
let endpoints: HttpHandler =

View File

@@ -71,7 +71,8 @@ module OpenFGA =
|> Seq.toArray
|> Array.map (fun t ->
let condition : Remoting.Condition option =
Option.ofObj t.Key.Condition
t.Key.Condition
|> Option.ofObj
|> Option.map (fun cond -> {
Name = cond.Name
Context = JsonSerializer.Serialize cond.Context
@@ -176,7 +177,23 @@ module OpenFGA =
result
let delete' (tuples: ClientTupleKey array) =
/// To be used with OpenFga.Sdk.Client.OpenFgaClient.DeleteTuples
let deleteTuples (tuples: Remoting.Tuple array) : ResizeArray<ClientTupleKeyWithoutCondition> =
let deletes: ClientTupleKeyWithoutCondition array =
tuples
|> Array.map (fun tuple ->
let result = ClientTupleKeyWithoutCondition ()
do result.Object <- tuple.Object
do result.Relation <- tuple.Relation
do result.User <- tuple.User
result
)
ResizeArray deletes
let delete' (tuples: ClientTupleKey array) : ClientWriteRequest =
let result = ClientWriteRequest ()
let deletes: ClientTupleKeyWithoutCondition array =
@@ -195,6 +212,22 @@ module OpenFGA =
result
/// To be used with OpenFga.Sdk.Client.OpenFgaClient.DeleteTuples
let deleteTuples' (tuples: ClientTupleKey array) : ResizeArray<ClientTupleKeyWithoutCondition> =
let deletes: ClientTupleKeyWithoutCondition array =
tuples
|> Array.map (fun tuple ->
let result = ClientTupleKeyWithoutCondition ()
do result.Object <- tuple.Object
do result.Relation <- tuple.Relation
do result.User <- tuple.User
result
)
ResizeArray deletes
let write (tuples: Remoting.Tuple array) =
let result = ClientWriteRequest ()
@@ -310,9 +343,10 @@ module OpenFGA =
let logger = ctx.GetLogger<Remoting.Api.OpenFGA> ()
let fga = ctx.GetService<OpenFgaClient> ()
try
let deleteRequest = Queries.delete [| tuple |]
do logger.LogInformation ("Delete req: {Request}", deleteRequest.ToJson ())
let! resp = fga.Write deleteRequest |> Async.AwaitTask
let deleteRequest = Queries.deleteTuples [| tuple |]
let json = JsonSerializer.Serialize deleteRequest
do logger.LogInformation ("Delete req: {Request}", json)
let! resp = fga.DeleteTuples deleteRequest |> Async.AwaitTask
do logger.LogInformation ("Delete resp: {Response}", resp.ToJson ())
return Ok (resp.Deletes.Count >= 1)
with e ->

View File

@@ -207,8 +207,16 @@ let configureApp (app: IApplicationBuilder) =
.UseStaticFiles()
.UseGiraffe webApp
let private getIp (uri: Uri) =
uri.DnsSafeHost
|> Net.Dns.GetHostAddresses
|> Array.head
let configureServices (settings: Settings) (services: IServiceCollection) =
let authSettings = settings.Auth
let uri = Uri settings.Fga.apiUrl
let ip = getIp uri
eprintfn "OpenFGA uri is %s (%s)" uri.DnsSafeHost (string ip)
let fga: OpenFgaClient = Fga.newFgaClient settings.Fga
let archmaesterDatasource = Archmaester.getDataSource settings.DbConnectionString
do Oceanbox.DataAgent.Dapper.register ()
@@ -297,4 +305,4 @@ let main args =
return 0
}
)
)

View File

@@ -126,6 +126,11 @@ module Remoting =
type ArchiveRelation =
| ViewTerm of ViewTerm
| ExecTicket of ExecTicket
with
static member ConditionName (permission: ArchiveRelation) =
match permission with
| ViewTerm _ -> "term"
| ExecTicket _ -> "ticket"
[<Struct>]
type AddArchiveGroupsRequest = {
@@ -196,27 +201,41 @@ module Remoting =
Permissions: ArchiveRelation array
}
[<Struct>]
type Permission = {
Name: string
Enabled: bool
}
[<Struct>]
type UserPermissionRequest = {
User: string
Permissions: Permission array
}
[<RequireQualifiedAccess>]
module Api =
type Auth = { IsAuthenticated: Async<bool> }
type Admin = {
addUsers: AddUsersRequest -> Async<Result<unit, string>>
addArchive: AddArchiveRequest -> Async<Result<Archive, string>>
addArchiveGroups: AddArchiveGroupsRequest -> Async<Result<unit, string>>
addGroupPermissions: AddGroupPermissionsRequest -> Async<Result<unit, string>>
addUsers: AddUsersRequest -> Async<Result<unit, string>>
deleteArchive: Archmaester.Dto.ArchiveId -> Async<Result<bool, string>>
getAllGroups: Async<string array>
getArchive: Archmaester.Dto.ArchiveId -> Async<Result<Archmaester.Dto.ArchiveProps, string>>
getArchiveCount: Archmaester.Dto.ArchiveFilter -> Async<Result<int, string>>
getArchiveDataSet: System.Guid -> Async<Result<DataSet, string>>
getArchiveRefs: Archmaester.Dto.ArchiveFilter -> Async<Result<Archmaester.Dto.ArchiveProps array, string>>
getArchiveTypes: unit -> Async<Archmaester.Dto.ArchiveType array>
getArchives: int -> int -> Archmaester.Dto.ArchiveFilter -> Async<Result<Archmaester.Dto.ArchiveProps array, string>>
getDataSets: unit -> Async<Result<DataSet array, string>>
getGroupUsers: string -> Async<string array>
removeUsers: string array -> Async<Result<unit, string>>
getDataSets: unit -> Async<Result<DataSet array, string>>
getArchiveDataSet: System.Guid -> Async<Result<DataSet, string>>
addArchive: AddArchiveRequest -> Async<Result<Archive, string>>
setUserPermissions: UserPermissionRequest -> Async<Result<unit, string>>
updateArchive: System.Guid -> EditArchiveRequest -> Async<Result<Archive, string>>
updateGroupPermissions: AddGroupPermissionsRequest -> Async<Result<unit, string>>
}
type OpenFGA = {

View File

@@ -83,6 +83,15 @@ spec:
envFrom:
- secretRef:
name: azure-keyvault
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop:
- "ALL"
runAsNonRoot: true
runAsUser: 1000
seccompProfile:
type: "RuntimeDefault"
dnsPolicy: ClusterFirst
restartPolicy: Always
schedulerName: default-scheduler

View File

@@ -249,10 +249,11 @@ let ssoCookieOptions (settings: MultiAuthSettings) (o: CookieAuthenticationOptio
o.ExpireTimeSpan <- TimeSpan.FromHours settings.sso.ttl
o.Events.OnValidatePrincipal <-
fun ctx ->
if ctx.Principal.HasClaim(fun x -> x.Type = "impersonating") then
Task.FromResult ()
else
updatePrincipalContext settings.oidc ctx
task {
let isImpersonating = ctx.Principal.HasClaim(fun x -> x.Type = "impersonating")
if not isImpersonating then
do! updatePrincipalContext settings.oidc ctx
}
let oidOptions (settings: MultiAuthSettings) (o: OpenIdConnectOptions) =
o.Authority <- settings.oidc.issuer
@@ -272,6 +273,7 @@ let oidOptions (settings: MultiAuthSettings) (o: OpenIdConnectOptions) =
|]
o.Scope.Clear ()
settings.oidc.scopes |> Array.iter o.Scope.Add
// NOTE(simkir): Do not store token in cookie. Reducing the size.
o.SaveTokens <- false
o.UseTokenLifetime <- true
o.ResponseType <- OpenIdConnectResponseType.Code
@@ -672,4 +674,4 @@ type ApplicationBuilder with
state with
ServicesConfig = service :: state.ServicesConfig
AppConfigs = middleware :: state.AppConfigs
}
}

View File

@@ -79,7 +79,8 @@ let corsPolicy (policy: CorsPolicyBuilder) =
.AllowAnyHeader()
.AllowAnyMethod()
.WithOrigins(appsettings.allowedOrigins)
|> ignore
.SetIsOriginAllowedToAllowWildcardSubdomains()
|> ignore
let configureServices (service: IServiceCollection) =
service

View File

@@ -42,7 +42,7 @@
},
"plainAuthUsers": [],
"fga": {
"apiUrl": "https://openfga.srv.oceanbox.io",
"apiUrl": "https://openfga.dev.oceanbox.io",
"apiKey": "",
"storeId": "01JKTZXMP7ANN4GG2P5W8Y56M6",
"modelId": "01JKTZYMCZZBVSBG66W27XMW0A"
@@ -53,11 +53,10 @@
"http://localhost:8085",
"http://localhost:8080",
"https://localhost:8080",
"https://sorcerer.local.oceanbox.io:8080",
"https://atlantis.local.oceanbox.io:8080",
"https://<x>-atlantis.dev.oceanbox.io",
"https://<x>-sorcerer.ekman.oceanbox.io",
"http://<x>-sorcerer.ekman.oceanbox.io"
"http://*.oceanbox.io",
"https://*.oceanbox.io",
"http://*.oceanbox.io:8080",
"https://*.oceanbox.io:8080",
],
"appName": "sorcerer",
"appEnv": "<x>",