manage user permissions

This commit is contained in:
David Radcliffe 2024-09-01 20:50:49 -04:00
parent 4a82064521
commit fa0b0d74b7
19 changed files with 469 additions and 44 deletions

View File

@ -10,6 +10,7 @@ type DatabaseRepositoryType string
type InstallationVerificationStatus string
type InstallationQuotaStatus string
type UserRole string
type Permission string
const (
ResourceListPageSize int = 20
@ -54,4 +55,7 @@ const (
UserRoleUser UserRole = "user"
UserRoleAdmin UserRole = "admin"
PermissionManageSources Permission = "manage_sources"
PermissionRead Permission = "read"
)

View File

@ -192,6 +192,104 @@ func (gr *GormRepository) GetUsers(ctx context.Context) ([]models.User, error) {
return sanitizedUsers, result.Error
}
func (gr *GormRepository) GetUser(ctx context.Context, userID uuid.UUID) (*models.FrontendUser, error) {
var dbUser models.User
var user models.FrontendUser
result := gr.GormClient.WithContext(ctx).First(&dbUser, userID)
if result.Error != nil {
return nil, result.Error
}
user.ID = dbUser.ID
user.FullName = dbUser.FullName
user.Email = dbUser.Email
user.Username = dbUser.Username
user.Role = dbUser.Role
// Populate ACLs for the user
var acls []models.UserPermission
if err := gr.GormClient.WithContext(ctx).
Where("user_id = ?", user.ID).
Find(&acls).Error; err != nil {
return nil, err
}
user.Permissions = make(map[string]map[string]bool)
for _, acl := range acls {
if _, exists := user.Permissions[acl.TargetUserID.String()]; !exists {
user.Permissions[acl.TargetUserID.String()] = make(map[string]bool)
}
user.Permissions[acl.TargetUserID.String()][string(acl.Permission)] = true
}
return &user, nil
}
func (gr *GormRepository) UpdateUserAndPermissions(ctx context.Context, user models.FrontendUser) error {
// Lookup user from the db
var dbUser models.User
result := gr.GormClient.WithContext(ctx).First(&dbUser, user.ID)
if result.Error != nil {
return result.Error
}
// Update fields on User
result = gr.GormClient.WithContext(ctx).Model(dbUser).Updates(map[string]interface{}{"full_name": user.FullName, "username": user.Username, "email": user.Email, "role": user.Role})
if result.Error != nil {
return result.Error
}
// Update User Permissions
var existingPermissions []models.UserPermission
if err := gr.GormClient.WithContext(ctx).
Where("user_id = ?", user.ID).
Find(&existingPermissions).Error; err != nil {
return err
}
for targetUserId, permissions := range user.Permissions {
for permission, value := range permissions {
if !value {
continue
}
// Check if the permission already exists
exists := false
for _, existingPermission := range existingPermissions {
if existingPermission.TargetUserID.String() == targetUserId && string(existingPermission.Permission) == permission {
exists = true
break
}
}
if !exists {
// Add new permission
p := models.UserPermission{
UserID: user.ID,
TargetUserID: uuid.Must(uuid.Parse(targetUserId)),
Permission: pkg.Permission(permission),
}
err := gr.GormClient.WithContext(ctx).Create(&p).Error
if err != nil {
return err
}
}
}
}
// Remove permissions that are no longer in user.Permissions
for _, existingPermission := range existingPermissions {
targetUserId := existingPermission.TargetUserID.String()
permission := string(existingPermission.Permission)
// Check if the permission still exists in the new user.Permissions
if _, exists := user.Permissions[targetUserId]; !exists || !user.Permissions[targetUserId][permission] {
// Permission no longer exists, so delete it
err := gr.GormClient.WithContext(ctx).Delete(&existingPermission).Error
if err != nil {
return err
}
}
}
return nil
}
//</editor-fold>
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////

View File

@ -11,6 +11,7 @@ import (
_20240114103850 "github.com/fastenhealth/fasten-onprem/backend/pkg/database/migrations/20240114103850"
_20240208112210 "github.com/fastenhealth/fasten-onprem/backend/pkg/database/migrations/20240208112210"
_20240813222836 "github.com/fastenhealth/fasten-onprem/backend/pkg/database/migrations/20240813222836"
_20240827214347 "github.com/fastenhealth/fasten-onprem/backend/pkg/database/migrations/20240827214347"
"github.com/fastenhealth/fasten-onprem/backend/pkg/models"
databaseModel "github.com/fastenhealth/fasten-onprem/backend/pkg/models/database"
sourceCatalog "github.com/fastenhealth/fasten-sources/catalog"
@ -225,6 +226,20 @@ func (gr *GormRepository) Migrate() error {
return nil
},
},
{
ID: "20240827214347", // add UserPermission model
Migrate: func(tx *gorm.DB) error {
err := tx.AutoMigrate(
&_20240827214347.UserPermission{},
)
if err != nil {
return err
}
return nil
},
},
})
// run when database is empty

View File

@ -20,6 +20,8 @@ type DatabaseRepository interface {
GetCurrentUser(ctx context.Context) (*models.User, error)
DeleteCurrentUser(ctx context.Context) error
GetUsers(ctx context.Context) ([]models.User, error)
GetUser(ctx context.Context, userId uuid.UUID) (*models.FrontendUser, error)
UpdateUserAndPermissions(ctx context.Context, user models.FrontendUser) error
GetSummary(ctx context.Context) (*models.Summary, error)

View File

@ -0,0 +1,20 @@
package _20240827214347
import (
"github.com/fastenhealth/fasten-onprem/backend/pkg/models"
"github.com/google/uuid"
)
type Permission string
const (
PermissionManageSources Permission = "manage_sources"
PermissionRead Permission = "read"
)
type UserPermission struct {
models.ModelBase
UserID uuid.UUID `json:"user_id" gorm:"type:uuid"`
TargetUserID uuid.UUID `json:"target_user_id" gorm:"type:uuid"`
Permission Permission `json:"permission"`
}

View File

@ -357,6 +357,21 @@ func (mr *MockDatabaseRepositoryMockRecorder) GetSummary(ctx interface{}) *gomoc
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSummary", reflect.TypeOf((*MockDatabaseRepository)(nil).GetSummary), ctx)
}
// GetUser mocks base method.
func (m *MockDatabaseRepository) GetUser(ctx context.Context, userId uuid.UUID) (*models.FrontendUser, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetUser", ctx, userId)
ret0, _ := ret[0].(*models.FrontendUser)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetUser indicates an expected call of GetUser.
func (mr *MockDatabaseRepositoryMockRecorder) GetUser(ctx, userId interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUser", reflect.TypeOf((*MockDatabaseRepository)(nil).GetUser), ctx, userId)
}
// GetUserByUsername mocks base method.
func (m *MockDatabaseRepository) GetUserByUsername(arg0 context.Context, arg1 string) (*models.User, error) {
m.ctrl.T.Helper()
@ -575,6 +590,20 @@ func (mr *MockDatabaseRepositoryMockRecorder) UpdateSource(ctx, sourceCreds inte
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateSource", reflect.TypeOf((*MockDatabaseRepository)(nil).UpdateSource), ctx, sourceCreds)
}
// UpdateUserAndPermissions mocks base method.
func (m *MockDatabaseRepository) UpdateUserAndPermissions(ctx context.Context, user models.FrontendUser) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateUserAndPermissions", ctx, user)
ret0, _ := ret[0].(error)
return ret0
}
// UpdateUserAndPermissions indicates an expected call of UpdateUserAndPermissions.
func (mr *MockDatabaseRepositoryMockRecorder) UpdateUserAndPermissions(ctx, user interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserAndPermissions", reflect.TypeOf((*MockDatabaseRepository)(nil).UpdateUserAndPermissions), ctx, user)
}
// UpsertRawResource mocks base method.
func (m *MockDatabaseRepository) UpsertRawResource(ctx context.Context, sourceCredentials models0.SourceCredential, rawResource models0.RawResourceFhir) (bool, error) {
m.ctrl.T.Helper()

View File

@ -11,14 +11,19 @@ import (
type User struct {
ModelBase
FullName string `json:"full_name"`
Username string `json:"username" gorm:"unique"`
Password string `json:"password"`
FullName string `json:"full_name"`
Username string `json:"username" gorm:"unique"`
Password string `json:"password"`
Picture string `json:"picture"`
Email string `json:"email"`
Role pkg.UserRole `json:"role"`
}
//additional optional metadata that Fasten stores with users
Picture string `json:"picture"`
Email string `json:"email"`
Role pkg.UserRole `json:"role"`
// FrontendUser is User with the addition of Permissions arranged
// as we want for sending to and from the frontend
type FrontendUser struct {
User
Permissions map[string]map[string]bool `json:"permissions"`
}
func (user *User) HashPassword(password string) error {

View File

@ -0,0 +1,13 @@
package models
import (
"github.com/fastenhealth/fasten-onprem/backend/pkg"
"github.com/google/uuid"
)
type UserPermission struct {
ModelBase
UserID uuid.UUID `json:"user_id" gorm:"type:uuid"`
TargetUserID uuid.UUID `json:"target_user_id" gorm:"type:uuid"`
Permission pkg.Permission `json:"permission"`
}

View File

@ -8,6 +8,7 @@ import (
"github.com/fastenhealth/fasten-onprem/backend/pkg/database"
"github.com/fastenhealth/fasten-onprem/backend/pkg/models"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
)
@ -54,3 +55,43 @@ func CreateUser(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "data": newUser})
}
func GetUser(c *gin.Context) {
if !IsAdmin(c) {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "Unauthorized"})
return
}
databaseRepo := c.MustGet(pkg.ContextKeyTypeDatabase).(database.DatabaseRepository)
user, err := databaseRepo.GetUser(c, uuid.MustParse(c.Param("userId")))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(200, gin.H{"success": true, "data": user})
}
func UpdateUser(c *gin.Context) {
if !IsAdmin(c) {
c.JSON(http.StatusUnauthorized, gin.H{"success": false, "error": "Unauthorized"})
return
}
databaseRepo := c.MustGet(pkg.ContextKeyTypeDatabase).(database.DatabaseRepository)
var user models.FrontendUser
if err := c.ShouldBindJSON(&user); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()})
return
}
err := databaseRepo.UpdateUserAndPermissions(c, user)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(200, gin.H{"success": true, "data": user})
}

View File

@ -128,6 +128,8 @@ func (ae *AppEngine) Setup() (*gin.RouterGroup, *gin.Engine) {
secure.GET("/users", handler.GetUsers)
secure.POST("/users", handler.CreateUser)
secure.GET("/users/:userId", handler.GetUser)
secure.POST("/users/:userId", handler.UpdateUser)
//server-side-events handler (only supported on mac/linux)
// TODO: causes deadlock on Windows

View File

@ -21,6 +21,7 @@ import { ResourceCreatorComponent } from './pages/resource-creator/resource-crea
import { ResourceDetailComponent } from './pages/resource-detail/resource-detail.component';
import { SourceDetailComponent } from './pages/source-detail/source-detail.component';
import { UserCreateComponent } from './pages/user-create/user-create.component';
import { UserEditComponent } from "./pages/user-edit/user-edit.component";
import { UserListComponent } from './pages/user-list/user-list.component';
const routes: Routes = [
@ -55,6 +56,7 @@ const routes: Routes = [
{ path: 'users', component: UserListComponent, canActivate: [ IsAuthenticatedAuthGuard, IsAdminAuthGuard ] },
{ path: 'users/new', component: UserCreateComponent, canActivate: [ IsAuthenticatedAuthGuard, IsAdminAuthGuard ] },
{ path: 'users/:user_id', component: UserEditComponent, canActivate: [ IsAuthenticatedAuthGuard, IsAdminAuthGuard ] },
// { path: 'general-pages', loadChildren: () => import('./general-pages/general-pages.module').then(m => m.GeneralPagesModule) },
// { path: 'ui-elements', loadChildren: () => import('./ui-elements/ui-elements.module').then(m => m.UiElementsModule) },

View File

@ -1,8 +1,18 @@
export const POSSIBLE_PERMISSIONS = [
{ name: 'Manage Sources', value: 'manage_sources' },
{ name: 'Read', value: 'read' },
]
export class User {
user_id?: number
id?: string
full_name?: string
username?: string
email?: string
password?: string
role?: string
permissions?: {
[targetUserId: string]: {
[key in typeof POSSIBLE_PERMISSIONS[number]['value']]: boolean;
}
}
}

View File

@ -0,0 +1,63 @@
<div class="az-content">
<div class="container">
<div class="az-content-body">
<h2 class="az-content-title">Edit User</h2>
<div *ngIf="loading" class="text-center">
<div class="spinner-border" role="status">
<span class="sr-only">Loading...</span>
</div>
</div>
<div *ngIf="errorMessage" class="alert alert-danger alert-dismissible fade show" role="alert">
{{ errorMessage }}
<button type="button" class="close" data-dismiss="alert" aria-label="Close" (click)="errorMessage = null">
<span aria-hidden="true">&times;</span>
</button>
</div>
<form *ngIf="!loading && !errorMessage" [formGroup]="userForm" (ngSubmit)="onSubmit()" class="pb-5 mb-5">
<div class="form-group">
<label for="full_name">Full Name</label>
<input type="text" id="full_name" formControlName="full_name" class="form-control" required>
</div>
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" formControlName="username" class="form-control" required>
</div>
<div class="form-group">
<label for="role">Role</label>
<select id="role" formControlName="role" class="form-control" required>
<option value="user" selected>User</option>
<option value="admin">Admin</option>
</select>
<div *ngIf="userForm.get('role')?.value === 'admin'" class="text-danger mt-2">
<strong>Warning:</strong> This will allow full system access including the ability to manage other users.
</div>
</div>
<div class="form-group">
<label for="email">Email</label>
<input type="email" id="email" formControlName="email" class="form-control">
</div>
<h5>Access to other Users</h5>
<div formGroupName="permissions">
<div *ngFor="let otherUser of userList" class="">
<div class="text">{{ otherUser.full_name }}</div>
<div formGroupName="{{otherUser.id}}" class="ml-3 p-1">
<div *ngFor="let permission of permissionsList" class="form-check">
<input type="checkbox" [formControlName]="permission.value" class="form-check-input" id="{{otherUser.id}}_{{permission.value}}">
<label class="form-check-label" for="{{otherUser.id}}_{{permission.value}}">{{ permission.name }}</label>
</div>
</div>
</div>
</div>
<button type="submit" class="btn btn-az-primary mt-2" [disabled]="!userForm.valid || loading">
Update User
</button>
</form>
</div>
</div>
</div>

View File

@ -0,0 +1,91 @@
import { CommonModule } from '@angular/common';
import { Component, OnInit } from '@angular/core';
import { FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { ActivatedRoute, Router } from '@angular/router';
import { forkJoin } from 'rxjs';
import { ToastNotification, ToastType } from '../../models/fasten/toast';
import { POSSIBLE_PERMISSIONS, User } from '../../models/fasten/user';
import { AuthService } from '../../services/auth.service';
import { FastenApiService } from '../../services/fasten-api.service';
import { ToastService } from '../../services/toast.service';
@Component({
selector: 'app-user-edit',
templateUrl: './user-edit.component.html',
styleUrls: ['./user-edit.component.scss'],
standalone: true,
imports: [CommonModule, ReactiveFormsModule]
})
export class UserEditComponent implements OnInit {
userForm: FormGroup;
loading = false;
userId: string;
userList: User[];
errorMessage: string | null = null;
permissionsList = POSSIBLE_PERMISSIONS;
constructor(
private fb: FormBuilder,
private authService: AuthService,
private fastenApi: FastenApiService,
private toastService: ToastService,
private router: Router,
private route: ActivatedRoute,
) { }
ngOnInit(): void {
this.loading = true;
this.userId = this.route.snapshot.paramMap.get('user_id');
forkJoin([
this.fastenApi.getAllUsers(),
this.fastenApi.getUser(this.userId)
]).subscribe(([allUsers, user]) => {
this.userList = allUsers.filter(user => user.id !== this.userId).sort((a, b) => a.full_name.localeCompare(b.full_name));
this.userForm = this.fb.group({
full_name: [user.full_name, [Validators.required, Validators.minLength(2)]],
username: [user.username, [Validators.required, Validators.minLength(4)]],
email: [user.email, [Validators.email]],
role: [user.role, Validators.required],
permissions: this.fb.group({})
});
this.userList.forEach(otherUser => {
const pfg = (this.userForm.get('permissions') as FormGroup);
pfg.addControl(otherUser.id, this.fb.group({}));
this.permissionsList.forEach(permission => {
const isChecked = user.permissions?.[otherUser.id]?.[permission.value] ?? false;
(pfg.get(otherUser.id) as FormGroup).addControl(permission.value, new FormControl(isChecked));
});
});
this.loading = false;
},
(error) => {
this.errorMessage = error.message;
this.loading = false;
});
}
onSubmit() {
if (this.userForm.valid) {
this.loading = true;
this.errorMessage = null;
const user: User = this.userForm.value;
user.id = this.userId;
this.authService.updateUser(user).subscribe(
(response) => {
this.loading = false;
const toastNotification = new ToastNotification();
toastNotification.type = ToastType.Success;
toastNotification.message = 'User updated successfully';
this.toastService.show(toastNotification);
this.router.navigate(['/users']);
},
(error) => {
this.loading = false;
this.errorMessage = 'Error updating user: ' + error.message;
}
);
}
}
}

View File

@ -2,11 +2,13 @@
<div class="container">
<div class="az-content-body">
<h2 class="az-content-title">User List</h2>
<div *ngIf="loading" class="text-center">
<div class="spinner-border" role="status">
<span class="sr-only">Loading...</span>
</div>
</div>
<table *ngIf="!loading" class="table">
<thead>
<tr>
@ -14,6 +16,7 @@
<th>Username</th>
<th>Email</th>
<th>Role</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@ -22,6 +25,9 @@
<td>{{ user.username }}</td>
<td>{{ user.email }}</td>
<td><span class="badge badge-primary">{{ user.role }}</span></td>
<td>
<a class="mx-2" routerLink="/users/{{ user.id }}">Edit</a>
</td>
</tr>
</tbody>
</table>

View File

@ -24,13 +24,15 @@ export class UserListComponent implements OnInit {
loadUsers(): void {
this.loading = true;
this.fastenApi.getAllUsers().subscribe((users: User[]) => {
this.users = users;
this.loading = false;
},
error => {
this.fastenApi.getAllUsers().subscribe(
(users: User[]) => {
this.users = users;
this.loading = false;
},
(error: Error) => {
console.error('Error loading users:', error);
this.loading = false;
});
}
);
}
}

View File

@ -146,6 +146,21 @@ export class AuthService {
);
}
public updateUser(user: User): Observable<any> {
let fastenApiEndpointBase = GetEndpointAbsolutePath(globalThis.location, environment.fasten_api_endpoint_base);
return this._httpClient.post<ResponseWrapper>(`${fastenApiEndpointBase}/secure/users/${user.id}`, user)
.pipe(
catchError((error) => {
if (error.status === 400) {
// Extract error information from the response body
const errorBody = error.error;
return throwError(new Error(errorBody.error || error.message));
}
return throwError(error);
})
);
}
//TODO: now that we've moved to remote-first database, we can refactor and simplify this function significantly.
public async IsAuthenticated(): Promise<boolean> {
let authToken = this.GetAuthToken()

View File

@ -1,33 +1,27 @@
import {Inject, Injectable} from '@angular/core';
import { HttpClient, HttpHeaders } from '@angular/common/http';
import {Observable, of} from 'rxjs';
import { Router } from '@angular/router';
import {map} from 'rxjs/operators';
import {ResponseWrapper} from '../models/response-wrapper';
import {Source} from '../models/fasten/source';
import {User} from '../models/fasten/user';
import {ResourceFhir} from '../models/fasten/resource_fhir';
import {SourceSummary} from '../models/fasten/source-summary';
import {Summary} from '../models/fasten/summary';
import {AuthService} from './auth.service';
import {GetEndpointAbsolutePath} from '../../lib/utils/endpoint_absolute_path';
import {environment} from '../../environments/environment';
import {ValueSet} from 'fhir/r4';
import {AttachmentModel} from '../../lib/models/datatypes/attachment-model';
import {BinaryModel} from '../../lib/models/resources/binary-model';
import {HTTP_CLIENT_TOKEN} from "../dependency-injection";
import * as fhirpath from 'fhirpath';
import _ from 'lodash';
import {DashboardConfig} from '../models/widget/dashboard-config';
import {DashboardWidgetQuery} from '../models/widget/dashboard-widget-query';
import {ResourceGraphResponse} from '../models/fasten/resource-graph-response';
import { fetchEventSource } from '@microsoft/fetch-event-source';
import {BackgroundJob, BackgroundJobSyncData} from '../models/fasten/background-job';
import {SupportRequest} from '../models/fasten/support-request';
import {
List
} from 'fhir/r4';
import {FormRequestHealthSystem} from '../models/fasten/form-request-health-system';
import { HttpClient } from '@angular/common/http';
import { Inject, Injectable } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { List, ValueSet } from 'fhir/r4';
import { Observable, of } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { environment } from '../../environments/environment';
import { AttachmentModel } from '../../lib/models/datatypes/attachment-model';
import { BinaryModel } from '../../lib/models/resources/binary-model';
import { GetEndpointAbsolutePath } from '../../lib/utils/endpoint_absolute_path';
import { HTTP_CLIENT_TOKEN } from "../dependency-injection";
import { BackgroundJob, BackgroundJobSyncData } from '../models/fasten/background-job';
import { FormRequestHealthSystem } from '../models/fasten/form-request-health-system';
import { ResourceGraphResponse } from '../models/fasten/resource-graph-response';
import { ResourceFhir } from '../models/fasten/resource_fhir';
import { Source } from '../models/fasten/source';
import { SourceSummary } from '../models/fasten/source-summary';
import { Summary } from '../models/fasten/summary';
import { SupportRequest } from '../models/fasten/support-request';
import { User } from '../models/fasten/user';
import { ResponseWrapper } from '../models/response-wrapper';
import { DashboardConfig } from '../models/widget/dashboard-config';
import { DashboardWidgetQuery } from '../models/widget/dashboard-widget-query';
import { AuthService } from './auth.service';
@Injectable({
providedIn: 'root'
})
@ -368,4 +362,17 @@ export class FastenApiService {
);
}
getUser(userId: string): Observable<User> {
return this._httpClient.get<any>(`${GetEndpointAbsolutePath(globalThis.location, environment.fasten_api_endpoint_base)}/secure/users/${userId}`)
.pipe(
map((response: ResponseWrapper) => {
if (!response.success) {
throw new Error(response.error)
}
return response.data as User
}),
// catchError(() => of(null))
);
}
}