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:
@@ -23,6 +23,9 @@ max_line_length= 80
|
||||
indent_size = 2
|
||||
max_line_length = 80
|
||||
|
||||
[*.yaml]
|
||||
indent_size = 2
|
||||
|
||||
[*.fs]
|
||||
max_line_length= 120
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "10.0.0",
|
||||
"version": "10.0.100",
|
||||
"rollForward": "latestMinor"
|
||||
}
|
||||
}
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
@@ -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 [||]
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 =
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
@@ -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
|
||||
|]
|
||||
)
|
||||
)
|
||||
]
|
||||
|
||||
204
src/Codex/src/Client/GroupUser.fs
Normal file
204
src/Codex/src/Client/GroupUser.fs
Normal 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
|
||||
]
|
||||
]
|
||||
]
|
||||
@@ -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
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
]
|
||||
@@ -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
|
||||
@@ -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)
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
81
src/Codex/src/Client/Users/DeleteForm.fs
Normal file
81
src/Codex/src/Client/Users/DeleteForm.fs
Normal 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"
|
||||
]
|
||||
]
|
||||
42
src/Codex/src/Client/Users/OpenFgaList.fs
Normal file
42
src/Codex/src/Client/Users/OpenFgaList.fs
Normal 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"
|
||||
]
|
||||
)
|
||||
)
|
||||
]
|
||||
7
src/Codex/src/Client/Utils.fs
Normal file
7
src/Codex/src/Client/Utils.fs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace Oceanbox.Codex
|
||||
|
||||
module Utils =
|
||||
open Fable.Core
|
||||
open Feliz
|
||||
|
||||
let toReact (el: JSX.Element) : ReactElement = unbox el
|
||||
@@ -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 =
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,8 @@ let corsPolicy (policy: CorsPolicyBuilder) =
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod()
|
||||
.WithOrigins(appsettings.allowedOrigins)
|
||||
|> ignore
|
||||
.SetIsOriginAllowedToAllowWildcardSubdomains()
|
||||
|> ignore
|
||||
|
||||
let configureServices (service: IServiceCollection) =
|
||||
service
|
||||
|
||||
@@ -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>",
|
||||
|
||||
Reference in New Issue
Block a user