Skip to content

Commit

Permalink
feat: add user group db schema support (pinterest#1144)
Browse files Browse the repository at this point in the history
* feat: add user group db schema support

* comments

* disable user group indexing

* addto sidebar

* fix linter

* more comments

* fix linter

* typo
  • Loading branch information
jczhong84 authored and aidenprice committed Jan 3, 2024
1 parent 1ff016d commit 25b127d
Show file tree
Hide file tree
Showing 15 changed files with 251 additions and 22 deletions.
33 changes: 33 additions & 0 deletions docs_website/docs/developer_guide/users_and_groups.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
id: users_and_groups
title: Users and Groups
sidebar_label: Users and Groups
---

Querybook supports adding/loading users and user groups.

## DB Schema
We use the `User` model to represent a user or a group.

A user or a group can have below properties
- username: It is the unique identifier of a user or a group.
- fullname: Full name or display name of a user or a group.
- password: Password of a user. Only applies to users when using default user/password auth.
- email: Email of a user or a group.
- profile_img: Profile image url of a user or a group.
- deleted: To indicate if this user has been deleted/deactivated or not.
- is_group: To indicate it is a user group if it's true.
- properties: Any addiontal properties are stored in this column. It is a freeform JSON object, you can basically add any properties inside it. By default all the properties inside `properties` are private, which are invisiable to end users.
- public_info: Only properties stored inside `properties.public_info` are visible to end users.
- description: [optional] Description of a user or a group.


For the detailed DB schema and table relationships, please check the model file [here](https://github.com/pinterest/querybook/blob/master/querybook/server/models/user.py)


## Create/Load Users and Group
For users
- default user/password authentication: people can sign up as a new user on UI.
- oauth/ldap authentication: they both support auto-creation of users.

For user groups, they can only be sync'ed now and can not be created on UI. You can have a scheduled task for your organization to sync them from either ldap, metastore or any other system which contains user groups.
3 changes: 2 additions & 1 deletion docs_website/sidebars.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@
"developer_guide/developer_setup",
"developer_guide/reinitialize_es",
"developer_guide/run_db_migration",
"developer_guide/storybook"
"developer_guide/storybook",
"developer_guide/users_and_groups"
],

"User Guide": ["user_guide/api_token", "user_guide/faq"],
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "querybook",
"version": "3.17.1",
"version": "3.18.0",
"description": "A Big Data Webapp",
"private": true,
"scripts": {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""add user group support
Revision ID: 1b8aba201c94
Revises: 27ed76f75106
Create Date: 2023-02-03 00:31:09.209132
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql

# revision identifiers, used by Alembic.
revision = "1b8aba201c94"
down_revision = "27ed76f75106"
branch_labels = None
depends_on = None


def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"user_group_member",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("gid", sa.Integer(), nullable=False),
sa.Column("uid", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["gid"], ["user.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["uid"], ["user.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.add_column("user", sa.Column("is_group", sa.Boolean(), nullable=True))
# ### end Alembic commands ###


def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("user", "is_group")
op.drop_table("user_group_member")
# ### end Alembic commands ###
10 changes: 10 additions & 0 deletions querybook/server/datasources/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,16 @@ def get_user_info(uid):
return user


@register("/user/<int:gid>/group_members/", methods=["GET"])
def get_user_group_members(gid):
group = logic.get_user_by_id(gid)

if group is None:
abort(RESOURCE_NOT_FOUND_STATUS_CODE)

return group.group_members


@register("/user/name/<username>/", methods=["GET"])
def get_user_info_by_username(username):
user = logic.get_user_by_name(username)
Expand Down
4 changes: 3 additions & 1 deletion querybook/server/logic/elasticsearch.py
Original file line number Diff line number Diff line change
Expand Up @@ -705,7 +705,9 @@ def _bulk_insert_users():
index_name = ES_CONFIG["users"]["index_name"]

for user in get_users_iter():
_insert(index_name, user["id"], user)
# skip indexing user groups before having the correct permission setup for it.
if not user.is_group:
_insert(index_name, user["id"], user)


def _bulk_update_users(fields: Set[str] = None):
Expand Down
19 changes: 19 additions & 0 deletions querybook/server/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,19 @@ class User(CRUDMixin, Base):
email = sql.Column(sql.String(length=name_length))
profile_img = sql.Column(sql.String(length=url_length))
deleted = sql.Column(sql.Boolean, default=False)
is_group = sql.Column(sql.Boolean, default=False)

properties = sql.Column(sql.JSON, default={})

settings = relationship("UserSetting", cascade="all, delete", passive_deletes=True)
roles = relationship("UserRole", cascade="all, delete", passive_deletes=True)
group_members = relationship(
"User",
secondary="user_group_member",
primaryjoin="User.id == UserGroupMember.gid",
secondaryjoin="User.id == UserGroupMember.uid",
backref="user_groups",
)

@hybrid_property
def password(self):
Expand Down Expand Up @@ -66,6 +74,8 @@ def to_dict(self, with_roles=False):
"profile_img": self.profile_img,
"email": self.email,
"deleted": self.deleted,
"is_group": self.is_group,
"properties": self.properties.get("public_info", {}),
}

if with_roles:
Expand Down Expand Up @@ -107,3 +117,12 @@ def to_dict(self):
"role": self.role.value,
"created_at": self.created_at,
}


class UserGroupMember(Base):
__tablename__ = "user_group_member"

id = sql.Column(sql.Integer, primary_key=True)
gid = sql.Column(sql.Integer, sql.ForeignKey("user.id", ondelete="CASCADE"))
uid = sql.Column(sql.Integer, sql.ForeignKey("user.id", ondelete="CASCADE"))
created_at = sql.Column(sql.DateTime, default=now)
Empty file.
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,13 @@ export const DataTableHeader: React.FunctionComponent<IDataTableHeader> = ({
);

const ownershipDOM = (tableOwnerships || []).map((ownership) => (
<UserBadge key={ownership.uid} uid={ownership.uid} mini cardStyle />
<UserBadge
key={ownership.uid}
uid={ownership.uid}
mini
cardStyle
groupPopover
/>
));

// Ownership cannot be removed if owner in db
Expand Down
59 changes: 41 additions & 18 deletions querybook/webapp/components/UserBadge/UserBadge.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import clsx from 'clsx';
import React, { useMemo } from 'react';

import { UserGroupCard } from 'components/UserGroupCard/UserGroupCard';
import { DELETED_USER_MSG } from 'const/user';
import { useUser } from 'hooks/redux/useUser';
import { Popover } from 'ui/Popover/Popover';
import { PopoverHoverWrapper } from 'ui/Popover/PopoverHoverWrapper';
import { AccentText } from 'ui/StyledText/StyledText';

import { ICommonUserLoaderProps } from './types';
Expand All @@ -15,6 +18,7 @@ type IProps = {
isOnline?: boolean;
mini?: boolean;
cardStyle?: boolean;
groupPopover?: boolean;
} & ICommonUserLoaderProps;

export const UserBadge: React.FunctionComponent<IProps> = ({
Expand All @@ -23,6 +27,7 @@ export const UserBadge: React.FunctionComponent<IProps> = ({
isOnline,
mini,
cardStyle,
groupPopover = true,
}) => {
const { loading, userInfo } = useUser({ uid, name });

Expand All @@ -46,24 +51,20 @@ export const UserBadge: React.FunctionComponent<IProps> = ({

const deletedText = userInfo?.deleted ? DELETED_USER_MSG : '';

if (mini) {
return (
<span
className={clsx({
UserBadge: true,
mini: true,
'card-style': cardStyle,
})}
>
<figure>{avatarDOM}</figure>
<AccentText className="username" weight="bold">
{userInfo?.fullname ?? userName} {deletedText}
</AccentText>
</span>
);
}

return (
const badgeDOM = mini ? (
<span
className={clsx({
UserBadge: true,
mini: true,
'card-style': cardStyle,
})}
>
<figure>{avatarDOM}</figure>
<AccentText className="username" weight="bold">
{userInfo?.fullname ?? userName} {deletedText}
</AccentText>
</span>
) : (
<div
className={clsx({
UserBadge: true,
Expand All @@ -89,4 +90,26 @@ export const UserBadge: React.FunctionComponent<IProps> = ({
</div>
</div>
);

return groupPopover && userInfo?.is_group ? (
<PopoverHoverWrapper>
{(showPopover, anchorElement) => (
<>
{badgeDOM}

{showPopover && (
<Popover
onHide={() => null}
anchor={anchorElement}
layout={['right', 'top']}
>
<UserGroupCard userGroup={userInfo} />
</Popover>
)}
</>
)}
</PopoverHoverWrapper>
) : (
badgeDOM
);
};
9 changes: 9 additions & 0 deletions querybook/webapp/components/UserGroupCard/UserGroupCard.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.UserGroupCard {
max-width: 400px;

.members-container {
display: flex;
align-items: center;
flex-wrap: wrap;
}
}
73 changes: 73 additions & 0 deletions querybook/webapp/components/UserGroupCard/UserGroupCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import React, { useCallback } from 'react';

import { UserBadge } from 'components/UserBadge/UserBadge';
import { MAX_USER_GROUP_MEMBERS_TO_SHOW } from 'const/uiConfig';
import { IUserInfo } from 'const/user';
import { useResource } from 'hooks/useResource';
import { UserResource } from 'resource/user';
import { AccentText } from 'ui/StyledText/StyledText';

import './UserGroupCard.scss';

interface IProps {
userGroup: IUserInfo;
}

export const UserGroupCard = ({ userGroup }: IProps) => {
const { data: members } = useResource(
useCallback(
() => UserResource.getUserGroupMembers(userGroup.id),
[userGroup.id]
)
);

const membersDOM = members && (
<>
{members.slice(0, MAX_USER_GROUP_MEMBERS_TO_SHOW).map((m) => (
<div key={m.id} className="flex-row ml8">
<UserBadge uid={m.id} mini />
</div>
))}
{members.length > MAX_USER_GROUP_MEMBERS_TO_SHOW && (
<div className="ml8"> and more</div>
)}
</>
);

return (
<div className="UserGroupCard">
<div>
<AccentText color="dark" weight="bold" size="xsmall">
{userGroup.fullname ?? userGroup.username}
</AccentText>
<AccentText color="light" size="xsmall">
{userGroup.username}
</AccentText>
</div>
{userGroup.email && (
<div className="mt8">
<AccentText color="dark" weight="bold" size="xsmall">
Email
</AccentText>
<AccentText color="light" size="xsmall">
{userGroup.email}
</AccentText>
</div>
)}
<div className="mt8">
<AccentText color="dark" weight="bold" size="xsmall">
Description
</AccentText>
<AccentText color="light" size="xsmall">
{userGroup.properties.description}
</AccentText>
</div>
<div className="mt8">
<AccentText color="dark" weight="bold" size="xsmall">
Group members
</AccentText>
<div className="members-container">{membersDOM}</div>
</div>
</div>
);
};
6 changes: 6 additions & 0 deletions querybook/webapp/const/uiConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,9 @@ export const MIN_ENGINE_TO_SHOW_FILTER = 4;
* the search bar in "hide columns" popover
*/
export const MIN_COLUMN_TO_SHOW_FILTER = 5;

/**
* this constant controls the maximum number
* of members to show in the user group popover
*/
export const MAX_USER_GROUP_MEMBERS_TO_SHOW = 10;
6 changes: 6 additions & 0 deletions querybook/webapp/const/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,14 @@ export interface IUserInfo {
fullname: string;
profile_img: string;
deleted: boolean;
email: string;
is_group: boolean;

roles?: number[];

properties: {
description?: string;
};
}

export interface IMyUserInfo {
Expand Down
2 changes: 2 additions & 0 deletions querybook/webapp/resource/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ export const UserResource = {
[visibleEnvironments: IEnvironment[], userEnvironmentIds: number[]]
>('/user/environment/'),
getNotifiers: () => ds.fetch<INotifier[]>('/user/notifiers/'),
getUserGroupMembers: (id: number) =>
ds.fetch<IUserInfo[]>(`/user/${id}/group_members/`),
};

export const UserSettingResource = {
Expand Down

0 comments on commit 25b127d

Please sign in to comment.