diff --git a/amber/src/main/scala/org/apache/texera/web/ServletAwareConfigurator.scala b/amber/src/main/scala/org/apache/texera/web/ServletAwareConfigurator.scala index 357c731ee3f..0a530503bcc 100644 --- a/amber/src/main/scala/org/apache/texera/web/ServletAwareConfigurator.scala +++ b/amber/src/main/scala/org/apache/texera/web/ServletAwareConfigurator.scala @@ -67,20 +67,11 @@ class ServletAwareConfigurator extends ServerEndpointConfig.Configurator with La s"User ID: $userId, User Name: $userName, User Email: $userEmail with CU Access: $cuAccess" ) - config.getUserProperties.put( - classOf[User].getName, - new User( - userId, - userName, - userEmail, - null, - null, - null, - null, - null, - null - ) - ) + val user = new User() + user.setUid(userId) + user.setName(userName) + user.setEmail(userEmail) + config.getUserProperties.put(classOf[User].getName, user) logger.debug(s"User created from headers: ID=$userId, Name=$userName") } else { // SINGLE NODE MODE: Construct the User object from JWT in query parameters. @@ -96,20 +87,11 @@ class ServletAwareConfigurator extends ServerEndpointConfig.Configurator with La .get("access-token") .map(token => { val claims = jwtConsumer.process(token).getJwtClaims - config.getUserProperties.put( - classOf[User].getName, - new User( - claims.getClaimValue("userId").asInstanceOf[Long].toInt, - claims.getSubject, - String.valueOf(claims.getClaimValue("email").asInstanceOf[String]), - null, - null, - null, - null, - null, - null - ) - ) + val user = new User() + user.setUid(claims.getClaimValue("userId").asInstanceOf[Long].toInt) + user.setName(claims.getSubject) + user.setEmail(String.valueOf(claims.getClaimValue("email").asInstanceOf[String])) + config.getUserProperties.put(classOf[User].getName, user) }) } } catch { diff --git a/amber/src/main/scala/org/apache/texera/web/auth/GuestAuthFilter.scala b/amber/src/main/scala/org/apache/texera/web/auth/GuestAuthFilter.scala index 40f90ee8eaa..65dc9f85baf 100644 --- a/amber/src/main/scala/org/apache/texera/web/auth/GuestAuthFilter.scala +++ b/amber/src/main/scala/org/apache/texera/web/auth/GuestAuthFilter.scala @@ -38,8 +38,12 @@ import javax.ws.rs.core.SecurityContext override protected def newInstance = new GuestAuthFilter } - val GUEST: User = - new User(null, "guest", null, null, null, null, UserRoleEnum.REGULAR, null, null) + val GUEST: User = { + val user = new User() + user.setName("guest") + user.setRole(UserRoleEnum.REGULAR) + user + } } @PreMatching diff --git a/amber/src/main/scala/org/apache/texera/web/auth/UserAuthenticator.scala b/amber/src/main/scala/org/apache/texera/web/auth/UserAuthenticator.scala index 57109273e3d..8b96728246a 100644 --- a/amber/src/main/scala/org/apache/texera/web/auth/UserAuthenticator.scala +++ b/amber/src/main/scala/org/apache/texera/web/auth/UserAuthenticator.scala @@ -43,8 +43,16 @@ object UserAuthenticator extends Authenticator[JwtContext, SessionUser] with Laz val comment = context.getJwtClaims.getClaimValue("comment").asInstanceOf[String] val accountCreation = context.getJwtClaims.getClaimValue("accountCreation").asInstanceOf[OffsetDateTime] - val user = - new User(userId, userName, email, null, googleId, null, role, comment, accountCreation) + + val user = new User() + user.setUid(userId) + user.setName(userName) + user.setEmail(email) + user.setGoogleId(googleId) + user.setRole(role) + user.setComment(comment) + user.setAccountCreationTime(accountCreation) + Optional.of(new SessionUser(user)) } catch { case e: Exception => diff --git a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/admin/user/AdminUserResource.scala b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/admin/user/AdminUserResource.scala index 03ccd9296e3..27469fd0ee2 100644 --- a/amber/src/main/scala/org/apache/texera/web/resource/dashboard/admin/user/AdminUserResource.scala +++ b/amber/src/main/scala/org/apache/texera/web/resource/dashboard/admin/user/AdminUserResource.scala @@ -45,7 +45,21 @@ case class UserInfo( googleAvatar: String, comment: String, lastLogin: java.time.OffsetDateTime, // will be null if never logged in - accountCreation: java.time.OffsetDateTime + accountCreation: java.time.OffsetDateTime, + permission: String // JSON string representing user permissions +) + +// Permission field schema definition +case class PermissionFieldSchema( + fieldType: String, // "boolean", "number", or "string" + possibleValues: List[Any], // List of possible values, empty list if not a category field + defaultValue: Any, // Default value for this permission + description: String // Human-readable description of what this permission does +) + +// Permission template containing all available permissions +case class PermissionTemplate( + permissions: Map[String, PermissionFieldSchema] ) object AdminUserResource { @@ -53,6 +67,18 @@ object AdminUserResource { .getInstance() .createDSLContext() final private lazy val userDao = new UserDao(context.configuration) + + // Define the permission template with all available permissions + val permissionTemplate: PermissionTemplate = PermissionTemplate( + permissions = Map( + "sshToComputingUnit" -> PermissionFieldSchema( + fieldType = "boolean", + possibleValues = List(true, false), + defaultValue = false, + description = "Allow user to access SSH terminal for computing units they have access to" + ) + ) + ) } @Path("/admin/user") @@ -78,7 +104,8 @@ class AdminUserResource { USER.GOOGLE_AVATAR, USER.COMMENT, USER_LAST_ACTIVE_TIME.LAST_ACTIVE_TIME, - USER.ACCOUNT_CREATION_TIME + USER.ACCOUNT_CREATION_TIME, + USER.PERMISSION ) .from(USER) .leftJoin(USER_LAST_ACTIVE_TIME) @@ -99,6 +126,7 @@ class AdminUserResource { updatedUser.setEmail(user.getEmail) updatedUser.setRole(user.getRole) updatedUser.setComment(user.getComment) + updatedUser.setPermission(user.getPermission) userDao.update(updatedUser) if (roleChanged) @@ -145,4 +173,17 @@ class AdminUserResource { def deleteCollection(@PathParam("eid") eid: Integer): Unit = { deleteExecutionCollection(eid) } + + /** + * Returns the permission template that describes all available user permissions + * including their types, possible values, and default values. + * + * @return PermissionTemplate containing the schema for all permissions + */ + @GET + @Path("/permission") + @Produces(Array(MediaType.APPLICATION_JSON)) + def getPermissionTemplate(): PermissionTemplate = { + AdminUserResource.permissionTemplate + } } diff --git a/common/auth/src/main/scala/org/apache/texera/auth/JwtParser.scala b/common/auth/src/main/scala/org/apache/texera/auth/JwtParser.scala index 1f7673c2752..2b9609df7b8 100644 --- a/common/auth/src/main/scala/org/apache/texera/auth/JwtParser.scala +++ b/common/auth/src/main/scala/org/apache/texera/auth/JwtParser.scala @@ -52,7 +52,12 @@ object JwtParser extends LazyLogging { val role = UserRoleEnum.valueOf(jwtClaims.getClaimValue("role").asInstanceOf[String]) val googleId = jwtClaims.getClaimValue("googleId", classOf[String]) - val user = new User(userId, userName, email, null, googleId, null, role, null, null) + val user = new User() + user.setUid(userId) + user.setName(userName) + user.setEmail(email) + user.setGoogleId(googleId) + user.setRole(role) Optional.of(new SessionUser(user)) } catch { case _: UnresolvableKeyException => diff --git a/frontend/src/app/app.module.ts b/frontend/src/app/app.module.ts index 257c2961fa4..809d675b8e6 100644 --- a/frontend/src/app/app.module.ts +++ b/frontend/src/app/app.module.ts @@ -176,6 +176,7 @@ import { AdminSettingsComponent } from "./dashboard/component/admin/settings/adm import { FormlyRepeatDndComponent } from "./common/formly/repeat-dnd/repeat-dnd.component"; import { NzInputNumberModule } from "ng-zorro-antd/input-number"; import { NzCheckboxModule } from "ng-zorro-antd/checkbox"; +import { PermissionEditModalComponent } from "./dashboard/component/admin/user/permission-edit-modal/permission-edit-modal.component"; registerLocaleData(en); @@ -268,6 +269,7 @@ registerLocaleData(en); HubSearchResultComponent, ComputingUnitSelectionComponent, AdminSettingsComponent, + PermissionEditModalComponent, ], imports: [ BrowserModule, diff --git a/frontend/src/app/common/type/user.ts b/frontend/src/app/common/type/user.ts index 4d8b02cc393..e37dac7297c 100644 --- a/frontend/src/app/common/type/user.ts +++ b/frontend/src/app/common/type/user.ts @@ -46,6 +46,7 @@ export interface User comment: string; lastLogin?: number; accountCreation?: Second; + permission?: string; // JSON string representing user permissions }> {} export interface File diff --git a/frontend/src/app/dashboard/component/admin/user/admin-user.component.html b/frontend/src/app/dashboard/component/admin/user/admin-user.component.html index 8e59e0d4c85..2610befe917 100644 --- a/frontend/src/app/dashboard/component/admin/user/admin-user.component.html +++ b/frontend/src/app/dashboard/component/admin/user/admin-user.component.html @@ -92,6 +92,7 @@ User Role Quota + Permission Account Creation Time @@ -294,6 +295,18 @@ nzType="dashboard"> + + + {{ ac | date:'MM/dd/y, h:mm a' }} @@ -310,3 +323,11 @@ Add + + + diff --git a/frontend/src/app/dashboard/component/admin/user/admin-user.component.ts b/frontend/src/app/dashboard/component/admin/user/admin-user.component.ts index e9e4ebbab88..f3773e54b04 100644 --- a/frontend/src/app/dashboard/component/admin/user/admin-user.component.ts +++ b/frontend/src/app/dashboard/component/admin/user/admin-user.component.ts @@ -22,7 +22,7 @@ import { UntilDestroy, untilDestroyed } from "@ngneat/until-destroy"; import { NzTableFilterFn, NzTableSortFn } from "ng-zorro-antd/table"; import { NzModalService } from "ng-zorro-antd/modal"; import { NzMessageService } from "ng-zorro-antd/message"; -import { AdminUserService } from "../../../service/admin/user/admin-user.service"; +import { AdminUserService, PermissionTemplate } from "../../../service/admin/user/admin-user.service"; import { MilliSecond, Role, User } from "../../../../common/type/user"; import { UserService } from "../../../../common/service/user/user.service"; import { UserQuotaComponent } from "../../user/user-quota/user-quota.component"; @@ -41,6 +41,7 @@ export class AdminUserComponent implements OnInit { editEmail: string = ""; editRole: Role = Role.REGULAR; editComment: string = ""; + editPermission: string = "{}"; nameSearchValue: string = ""; emailSearchValue: string = ""; commentSearchValue: string = ""; @@ -49,6 +50,9 @@ export class AdminUserComponent implements OnInit { commentSearchVisible = false; listOfDisplayUser = [...this.userList]; currentUid: number | undefined = 0; + permissionTemplate: PermissionTemplate | null = null; + isPermissionModalVisible: boolean = false; + selectedUserForPermission: User | null = null; @ViewChild("nameInput") nameInputRef?: ElementRef; @ViewChild("emailInput") emailInputRef?: ElementRef; @@ -72,6 +76,13 @@ export class AdminUserComponent implements OnInit { this.userList = userList; this.reset(); }); + + this.adminUserService + .getPermissionTemplate() + .pipe(untilDestroyed(this)) + .subscribe(template => { + this.permissionTemplate = template; + }); } public updateRole(user: User, role: Role): void { @@ -94,6 +105,7 @@ export class AdminUserComponent implements OnInit { this.editEmail = user.email; this.editRole = user.role; this.editComment = user.comment; + this.editPermission = user.permission || "{}"; setTimeout(() => { if (attribute === "name" && this.nameInputRef) { @@ -119,7 +131,8 @@ export class AdminUserComponent implements OnInit { (originalUser.name === this.editName && originalUser.email === this.editEmail && originalUser.comment === this.editComment && - originalUser.role === this.editRole) + originalUser.role === this.editRole && + (originalUser.permission || "{}") === this.editPermission) ) { this.stopEdit(); return; @@ -128,7 +141,7 @@ export class AdminUserComponent implements OnInit { const currentUid = this.editUid; this.stopEdit(); this.adminUserService - .updateUser(currentUid, this.editName, this.editEmail, this.editRole, this.editComment) + .updateUser(currentUid, this.editName, this.editEmail, this.editRole, this.editComment, this.editPermission) .pipe(untilDestroyed(this)) .subscribe({ next: () => this.ngOnInit(), @@ -186,6 +199,25 @@ export class AdminUserComponent implements OnInit { }); } + openPermissionModal(user: User): void { + this.selectedUserForPermission = user; + this.startEdit(user, "permission"); + this.isPermissionModalVisible = true; + } + + handlePermissionSave(editedPermission: string): void { + this.editPermission = editedPermission; + this.saveEdit(); + this.isPermissionModalVisible = false; + this.selectedUserForPermission = null; + } + + handlePermissionCancel(): void { + this.stopEdit(); + this.isPermissionModalVisible = false; + this.selectedUserForPermission = null; + } + isUserActive(user: User): boolean { if (!user.lastLogin) { return false; diff --git a/frontend/src/app/dashboard/component/admin/user/permission-edit-modal/permission-edit-modal.component.html b/frontend/src/app/dashboard/component/admin/user/permission-edit-modal/permission-edit-modal.component.html new file mode 100644 index 00000000000..0323ecee4a1 --- /dev/null +++ b/frontend/src/app/dashboard/component/admin/user/permission-edit-modal/permission-edit-modal.component.html @@ -0,0 +1,77 @@ + + + + +
+
+ +
+ +
+ + + + + + + + + + + + +
+
+

{{ permSchema.description }}

+
+
+ +
+ +
+
+
+
diff --git a/frontend/src/app/dashboard/component/admin/user/permission-edit-modal/permission-edit-modal.component.scss b/frontend/src/app/dashboard/component/admin/user/permission-edit-modal/permission-edit-modal.component.scss new file mode 100644 index 00000000000..c3f5a7fcd35 --- /dev/null +++ b/frontend/src/app/dashboard/component/admin/user/permission-edit-modal/permission-edit-modal.component.scss @@ -0,0 +1,59 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +.permission-edit-modal { + .permission-item { + margin-bottom: 20px; + + &:last-child { + margin-bottom: 0; + } + + .permission-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 4px; + + .permission-label { + font-weight: 600; + font-size: 14px; + color: rgba(0, 0, 0, 0.85); + flex: 1; + } + + .permission-control { + flex-shrink: 0; + margin-left: 16px; + } + } + + .permission-description { + font-size: 12px; + color: rgba(0, 0, 0, 0.45); + line-height: 1.5; + margin: 4px 0 0 0; + } + } + + .no-permissions { + padding: 40px 0; + text-align: center; + } +} diff --git a/frontend/src/app/dashboard/component/admin/user/permission-edit-modal/permission-edit-modal.component.ts b/frontend/src/app/dashboard/component/admin/user/permission-edit-modal/permission-edit-modal.component.ts new file mode 100644 index 00000000000..b0de34c8649 --- /dev/null +++ b/frontend/src/app/dashboard/component/admin/user/permission-edit-modal/permission-edit-modal.component.ts @@ -0,0 +1,72 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Component, EventEmitter, Input, OnChanges, Output } from "@angular/core"; +import { PermissionTemplate } from "../../../../service/admin/user/admin-user.service"; +import { User } from "../../../../../common/type/user"; + +@Component({ + selector: "texera-permission-edit-modal", + templateUrl: "./permission-edit-modal.component.html", + styleUrls: ["./permission-edit-modal.component.scss"], +}) +export class PermissionEditModalComponent implements OnChanges { + @Input() isVisible: boolean = false; + @Input() permissionTemplate: PermissionTemplate | null = null; + @Input() user: User | null = null; + @Output() save = new EventEmitter(); + @Output() cancel = new EventEmitter(); + + editPermission: string = "{}"; + permissionObject: any = {}; + modalTitle: string = "Edit Permissions"; + + ngOnChanges(): void { + if (this.user) { + this.modalTitle = `Edit Permissions for ${this.user.name}`; + this.editPermission = this.user.permission || "{}"; + this.permissionObject = this.parsePermission(this.editPermission); + } + } + + parsePermission(permission: string): any { + try { + return JSON.parse(permission); + } catch { + return {}; + } + } + + getPermissionKeys(): string[] { + return this.permissionTemplate ? Object.keys(this.permissionTemplate.permissions) : []; + } + + updatePermissionValue(permissionKey: string, value: any): void { + this.permissionObject[permissionKey] = value; + this.editPermission = JSON.stringify(this.permissionObject); + } + + handleCancel(): void { + this.cancel.emit(); + } + + handleSave(): void { + this.save.emit(this.editPermission); + } +} diff --git a/frontend/src/app/dashboard/service/admin/user/admin-user.service.ts b/frontend/src/app/dashboard/service/admin/user/admin-user.service.ts index 481c5e5302e..3d01a8e9057 100644 --- a/frontend/src/app/dashboard/service/admin/user/admin-user.service.ts +++ b/frontend/src/app/dashboard/service/admin/user/admin-user.service.ts @@ -24,10 +24,24 @@ import { AppSettings } from "../../../../common/app-setting"; import { ExecutionQuota, File, Role, User, Workflow } from "../../../../common/type/user"; import { DatasetQuota } from "src/app/dashboard/type/quota-statistic.interface"; +// Permission field schema definition +export interface PermissionFieldSchema { + fieldType: string; // "boolean", "number", or "string" + possibleValues: any[]; // List of possible values, empty list if not a category field + defaultValue: any; // Default value for this permission + description: string; // Human-readable description of what this permission does +} + +// Permission template containing all available permissions +export interface PermissionTemplate { + permissions: { [key: string]: PermissionFieldSchema }; +} + export const USER_BASE_URL = `${AppSettings.getApiEndpoint()}/admin/user`; export const USER_LIST_URL = `${USER_BASE_URL}/list`; export const USER_UPDATE_URL = `${USER_BASE_URL}/update`; export const USER_ADD_URL = `${USER_BASE_URL}/add`; +export const USER_PERMISSION_TEMPLATE_URL = `${USER_BASE_URL}/permission`; export const USER_CREATED_FILES = `${USER_BASE_URL}/uploaded_files`; export const USER_UPLOADED_DATASE_SIZE = `${USER_BASE_URL}/dataset_size`; export const USER_UPLOADED_DATASET_COUNT = `${USER_BASE_URL}/uploaded_dataset`; @@ -48,16 +62,28 @@ export class AdminUserService { return this.http.get>(`${USER_LIST_URL}`); } - public updateUser(uid: number, name: string, email: string, role: Role, comment: string): Observable { + public updateUser( + uid: number, + name: string, + email: string, + role: Role, + comment: string, + permission?: string + ): Observable { return this.http.put(`${USER_UPDATE_URL}`, { uid: uid, name: name, email: email, role: role, comment: comment, + permission: permission, }); } + public getPermissionTemplate(): Observable { + return this.http.get(`${USER_PERMISSION_TEMPLATE_URL}`); + } + public addUser(): Observable { return this.http.post(`${USER_ADD_URL}/`, {}); } diff --git a/sql/texera_ddl.sql b/sql/texera_ddl.sql index 7b0f9b9063d..229e791822b 100644 --- a/sql/texera_ddl.sql +++ b/sql/texera_ddl.sql @@ -100,6 +100,7 @@ CREATE TABLE IF NOT EXISTS "user" google_avatar VARCHAR(100), role user_role_enum NOT NULL DEFAULT 'INACTIVE', comment TEXT, + permission JSONB NOT NULL DEFAULT '{}'::jsonb, account_creation_time TIMESTAMPTZ NOT NULL DEFAULT now(), -- check that either password or google_id is not null CONSTRAINT ck_nulltest CHECK ((password IS NOT NULL) OR (google_id IS NOT NULL)) diff --git a/sql/updates/16.sql b/sql/updates/16.sql new file mode 100644 index 00000000000..b5988290388 --- /dev/null +++ b/sql/updates/16.sql @@ -0,0 +1,36 @@ +-- Licensed to the Apache Software Foundation (ASF) under one +-- or more contributor license agreements. See the NOTICE file +-- distributed with this work for additional information +-- regarding copyright ownership. The ASF licenses this file +-- to you under the Apache License, Version 2.0 (the +-- "License"); you may not use this file except in compliance +-- with the License. You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, +-- software distributed under the License is distributed on an +-- "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +-- KIND, either express or implied. See the License for the +-- specific language governing permissions and limitations +-- under the License. + +-- ============================================ +-- 1. Connect to the texera_db database +-- ============================================ +\c texera_db + +SET search_path TO texera_db; + +-- ============================================ +-- 2. Update the table schema +-- ============================================ +BEGIN; + +-- Add new column permission to user table. +-- Using JSONB type for better performance and indexing capabilities. +-- Default value is an empty JSON object '{}'. +ALTER TABLE "user" + ADD COLUMN permission JSONB NOT NULL DEFAULT '{}'::jsonb; + +COMMIT;