= ({ user }) => {
data-testid={`row-${user.id}`}
>
- {user.username}
+ {user.username}{" "}
+ {user.disabled && (
+
+ Invite sent
+
+ )}
+ |
+
+ {user.email_address}
|
{user.first_name}
diff --git a/clients/admin-ui/src/features/user-management/UserManagementTable.tsx b/clients/admin-ui/src/features/user-management/UserManagementTable.tsx
index 93b9e2f14c..f1ad2cd6b2 100644
--- a/clients/admin-ui/src/features/user-management/UserManagementTable.tsx
+++ b/clients/admin-ui/src/features/user-management/UserManagementTable.tsx
@@ -60,6 +60,7 @@ const UserManagementTable: React.FC = () => {
Username |
+ Email |
First Name |
Last Name |
Permissions |
diff --git a/clients/admin-ui/src/pages/login.tsx b/clients/admin-ui/src/pages/login.tsx
index 53d6b42cab..315f382534 100644
--- a/clients/admin-ui/src/pages/login.tsx
+++ b/clients/admin-ui/src/pages/login.tsx
@@ -3,42 +3,134 @@ import Image from "common/Image";
import {
Box,
Button,
+ Center,
chakra,
Flex,
- FormControl,
- FormErrorMessage,
- FormLabel,
Heading,
- Input,
Stack,
+ usePrefersReducedMotion,
useToast,
} from "fidesui";
import { Formik } from "formik";
+// Framer is bundled as part of chakra. TODO: had trouble with package.json's when
+// trying to make framer a first level dev dependency
+// eslint-disable-next-line import/no-extraneous-dependencies
+import { motion } from "framer-motion";
import type { NextPage } from "next";
import { useRouter } from "next/router";
+import { ParsedUrlQuery } from "querystring";
+import { useEffect, useState } from "react";
import { useDispatch, useSelector } from "react-redux";
+import * as Yup from "yup";
-import { login, selectToken, useLoginMutation } from "../features/auth";
+import {
+ login,
+ selectToken,
+ useAcceptInviteMutation,
+ useLoginMutation,
+} from "~/features/auth";
+import { CustomTextInput } from "~/features/common/form/inputs";
+import { passwordValidation } from "~/features/common/form/validation";
+
+const parseQueryParam = (query: ParsedUrlQuery) => {
+ const validPathRegex = /^\/[\w/-]*$/;
+ const {
+ username: rawUsername,
+ invite_code: rawInviteCode,
+ redirect: rawRedirect,
+ } = query;
+ const redirectDecoded =
+ typeof rawRedirect === "string"
+ ? decodeURIComponent(rawRedirect)
+ : undefined;
+ return {
+ username: typeof rawUsername === "string" ? rawUsername : undefined,
+ inviteCode: typeof rawInviteCode === "string" ? rawInviteCode : undefined,
+ redirect:
+ redirectDecoded && validPathRegex.test(redirectDecoded)
+ ? redirectDecoded
+ : undefined,
+ };
+};
+
+const Animation = () => {
+ const primary800 = "rgba(17, 20, 57, 1)";
+ const icon = {
+ hidden: {
+ opacity: 0,
+ pathLength: 0,
+ fill: "rgba(255, 255, 255, 0)",
+ },
+ visible: {
+ opacity: 1,
+ pathLength: 1,
+ fill: primary800,
+ },
+ };
+ return (
+
+
+
+
+
+ );
+};
const useLogin = () => {
- const [loginRequest, { isLoading }] = useLoginMutation();
+ const [loginRequest] = useLoginMutation();
+ const [acceptInviteRequest] = useAcceptInviteMutation();
+ const [showAnimation, setShowAnimation] = useState(false);
+ // If the user prefers no motion, don't show the animation
+ const reduceMotion = usePrefersReducedMotion();
const token = useSelector(selectToken);
const toast = useToast();
const router = useRouter();
const dispatch = useDispatch();
+ const { username, inviteCode, redirect } = parseQueryParam(router.query);
const initialValues = {
- email: "",
+ username: username ?? "",
password: "",
};
+ const isFromInvite = inviteCode !== undefined;
+
const onSubmit = async (values: typeof initialValues) => {
const credentials = {
- username: values.email,
+ username: values.username,
password: values.password,
};
+
try {
- const user = await loginRequest(credentials).unwrap();
+ let user;
+ if (isFromInvite) {
+ user = await acceptInviteRequest({
+ ...credentials,
+ inviteCode,
+ }).unwrap();
+ } else {
+ user = await loginRequest(credentials).unwrap();
+ }
+ setShowAnimation(true);
dispatch(login(user));
} catch (error) {
// eslint-disable-next-line no-console
@@ -51,47 +143,50 @@ const useLogin = () => {
}
};
- const validate = (values: typeof initialValues) => {
- const errors: {
- email?: string;
- password?: string;
- } = {};
-
- if (!values.email) {
- errors.email = "Required";
- }
+ const validationSchema = Yup.object().shape({
+ username: Yup.string().required().label("Username"),
+ password: isFromInvite
+ ? passwordValidation.label("Password")
+ : Yup.string().required().label("Password"),
+ });
- if (!values.password) {
- errors.password = "Required";
+ useEffect(() => {
+ if (token) {
+ const destination = redirect ?? "/";
+ if (showAnimation && !reduceMotion) {
+ const timer = setTimeout(() => {
+ router.push(destination).then(() => {
+ setShowAnimation(false);
+ });
+ }, 2000);
+ return () => {
+ clearTimeout(timer);
+ };
+ }
+ router.push(destination);
}
-
- return errors;
- };
-
- if (token) {
- router.push("/");
- }
+ return () => {};
+ }, [token, router, redirect, showAnimation, reduceMotion]);
return {
+ isFromInvite,
+ showAnimation,
+ inviteCode,
initialValues,
- isLoading,
onSubmit,
- validate,
+ validationSchema,
};
};
const Login: NextPage = () => {
- const { isLoading, ...formikProps } = useLogin();
+ const { isFromInvite, showAnimation, inviteCode, ...formikProps } =
+ useLogin();
+
+ const submitButtonText = isFromInvite ? "Setup user" : "Sign in";
+
return (
-
- {({
- errors,
- handleBlur,
- handleChange,
- handleSubmit,
- touched,
- values,
- }) => (
+
+ {({ handleSubmit, isValid, isSubmitting, dirty }) => (
@@ -155,66 +250,45 @@ const Login: NextPage = () => {
width="100%"
>
-
-
- Username
-
-
- {errors.email}
-
-
-
-
- Password
-
-
+
+
+
- {errors.password}
-
-
-
+ >
+ {showAnimation ? "" : submitButtonText}
+
+ {showAnimation ? : null}
+
diff --git a/clients/admin-ui/src/types/api/models/UserCreate.ts b/clients/admin-ui/src/types/api/models/UserCreate.ts
index 3558a53ce8..aa299ff717 100644
--- a/clients/admin-ui/src/types/api/models/UserCreate.ts
+++ b/clients/admin-ui/src/types/api/models/UserCreate.ts
@@ -7,7 +7,9 @@
*/
export type UserCreate = {
username: string;
- password: string;
+ password?: string;
+ email_address: string;
first_name?: string;
last_name?: string;
+ disabled?: boolean;
};
diff --git a/clients/admin-ui/src/types/api/models/UserResponse.ts b/clients/admin-ui/src/types/api/models/UserResponse.ts
index 525ebe4afa..c424be6d56 100644
--- a/clients/admin-ui/src/types/api/models/UserResponse.ts
+++ b/clients/admin-ui/src/types/api/models/UserResponse.ts
@@ -9,6 +9,9 @@ export type UserResponse = {
id: string;
username: string;
created_at: string;
+ email_address?: string;
first_name?: string;
last_name?: string;
+ disabled?: boolean;
+ disabled_reason?: string;
};
diff --git a/clients/admin-ui/src/types/api/models/UserUpdate.ts b/clients/admin-ui/src/types/api/models/UserUpdate.ts
index 92afa10e12..4bded69a43 100644
--- a/clients/admin-ui/src/types/api/models/UserUpdate.ts
+++ b/clients/admin-ui/src/types/api/models/UserUpdate.ts
@@ -3,9 +3,10 @@
/* eslint-disable */
/**
- * Data required to update a FidesopsUser
+ * Data required to update a FidesUser
*/
export type UserUpdate = {
+ email_address?: string;
first_name?: string;
last_name?: string;
};
diff --git a/src/fides/api/alembic/migrations/versions/31493e48c1d8_table_updates_for_user_invite.py b/src/fides/api/alembic/migrations/versions/31493e48c1d8_table_updates_for_user_invite.py
new file mode 100644
index 0000000000..9eba18a40c
--- /dev/null
+++ b/src/fides/api/alembic/migrations/versions/31493e48c1d8_table_updates_for_user_invite.py
@@ -0,0 +1,73 @@
+"""table updates for user invite
+
+Revision ID: 31493e48c1d8
+Revises: 4fb779cc5f17
+Create Date: 2024-03-08 18:05:31.392727
+
+"""
+
+import sqlalchemy as sa
+from alembic import op
+from citext import CIText
+
+# revision identifiers, used by Alembic.
+revision = "31493e48c1d8"
+down_revision = "4fb779cc5f17"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ op.execute("create type disabledreason as enum('pending_invite')")
+ op.create_table(
+ "fides_user_invite",
+ sa.Column("id", sa.String(length=255), nullable=False),
+ sa.Column(
+ "created_at",
+ sa.DateTime(timezone=True),
+ server_default=sa.text("now()"),
+ nullable=True,
+ ),
+ sa.Column(
+ "updated_at",
+ sa.DateTime(timezone=True),
+ server_default=sa.text("now()"),
+ nullable=True,
+ ),
+ sa.Column("username", CIText(), nullable=False),
+ sa.Column("hashed_invite_code", sa.String(), nullable=False),
+ sa.Column("salt", sa.String(), nullable=False),
+ sa.ForeignKeyConstraint(
+ ["username"], ["fidesuser.username"], ondelete="CASCADE"
+ ),
+ sa.PrimaryKeyConstraint("id", "username"),
+ )
+ op.create_index(
+ op.f("ix_fides_user_invite_id"), "fides_user_invite", ["id"], unique=False
+ )
+ op.add_column("fidesuser", sa.Column("email_address", CIText(), nullable=True))
+ op.add_column(
+ "fidesuser",
+ sa.Column("disabled", sa.Boolean(), nullable=False, server_default="f"),
+ )
+ op.add_column(
+ "fidesuser",
+ sa.Column(
+ "disabled_reason",
+ sa.Enum("pending_invite", name="disabledreason"),
+ nullable=True,
+ ),
+ )
+ op.create_unique_constraint(
+ "fidesuser_email_address", "fidesuser", ["email_address"]
+ )
+
+
+def downgrade():
+ op.drop_constraint("fidesuser_email_address", "fidesuser", type_="unique")
+ op.drop_column("fidesuser", "disabled_reason")
+ op.drop_column("fidesuser", "disabled")
+ op.drop_column("fidesuser", "email_address")
+ op.drop_index(op.f("ix_fides_user_invite_id"), table_name="fides_user_invite")
+ op.drop_table("fides_user_invite")
+ op.execute("drop type disabledreason")
diff --git a/src/fides/api/api/v1/endpoints/messaging_endpoints.py b/src/fides/api/api/v1/endpoints/messaging_endpoints.py
index 4772377b42..d721cd067e 100644
--- a/src/fides/api/api/v1/endpoints/messaging_endpoints.py
+++ b/src/fides/api/api/v1/endpoints/messaging_endpoints.py
@@ -28,6 +28,7 @@
MessagingConfig,
default_messaging_config_key,
default_messaging_config_name,
+ get_messaging_method,
get_schema_for_secrets,
)
from fides.api.models.messaging_template import (
@@ -46,6 +47,7 @@
MessagingConfigResponse,
MessagingConfigStatus,
MessagingConfigStatusMessage,
+ MessagingMethod,
MessagingServiceType,
MessagingTemplateDefault,
MessagingTemplateWithPropertiesBodyParams,
@@ -53,6 +55,7 @@
MessagingTemplateWithPropertiesPatchBodyParams,
MessagingTemplateWithPropertiesSummary,
TestMessagingStatusMessage,
+ UserEmailInviteStatus,
)
from fides.api.schemas.messaging.messaging_secrets_docs_only import (
possible_messaging_secrets,
@@ -90,6 +93,7 @@
MESSAGING_DEFAULT,
MESSAGING_DEFAULT_BY_TYPE,
MESSAGING_DEFAULT_SECRETS,
+ MESSAGING_EMAIL_INVITE_STATUS,
MESSAGING_SECRETS,
MESSAGING_STATUS,
MESSAGING_TEMPLATE_BY_ID,
@@ -224,7 +228,9 @@ def get_active_default_config(*, db: Session = Depends(deps.get_db)) -> Messagin
},
)
def get_messaging_status(
- *, db: Session = Depends(deps.get_db)
+ *,
+ db: Session = Depends(deps.get_db),
+ messaging_method: Optional[MessagingMethod] = None,
) -> MessagingConfigStatusMessage:
"""
Determines the status of the active default messaging config
@@ -233,10 +239,19 @@ def get_messaging_status(
# confirm an active default messaging config is present
messaging_config = MessagingConfig.get_active_default(db)
- if not messaging_config:
+
+ if not messaging_config or (
+ messaging_method
+ and get_messaging_method(messaging_config.service_type.value) # type: ignore
+ != messaging_method
+ ):
+ detail = "No active default messaging configuration found"
+ if messaging_method:
+ detail += f" for {messaging_method}"
+
return MessagingConfigStatusMessage(
config_status=MessagingConfigStatus.not_configured,
- detail="No active default messaging configuration found",
+ detail=detail,
)
try:
@@ -781,3 +796,21 @@ def delete_messaging_template_by_id(
)
except MessagingTemplateValidationException as e:
raise HTTPException(status_code=HTTP_400_BAD_REQUEST, detail=e.message)
+
+
+@router.get(MESSAGING_EMAIL_INVITE_STATUS)
+def user_email_invite_status(
+ db: Session = Depends(deps.get_db),
+ config_proxy: ConfigProxy = Depends(deps.get_config_proxy),
+) -> UserEmailInviteStatus:
+ """Returns whether or not all the necessary configurations are in place to be able to invite a user via email."""
+
+ messaging_status = get_messaging_status(
+ db=db, messaging_method=MessagingMethod.EMAIL
+ )
+ return UserEmailInviteStatus(
+ enabled=(
+ messaging_status.config_status == MessagingConfigStatus.configured
+ and config_proxy.admin_ui.url is not None
+ )
+ )
diff --git a/src/fides/api/api/v1/endpoints/oauth_endpoints.py b/src/fides/api/api/v1/endpoints/oauth_endpoints.py
index d2bf55d8b9..2f1cb82a33 100644
--- a/src/fides/api/api/v1/endpoints/oauth_endpoints.py
+++ b/src/fides/api/api/v1/endpoints/oauth_endpoints.py
@@ -24,6 +24,7 @@
from fides.api.models.authentication_request import AuthenticationRequest
from fides.api.models.client import ClientDetail
from fides.api.models.connectionconfig import ConnectionConfig, ConnectionTestStatus
+from fides.api.models.fides_user import FidesUser
from fides.api.oauth.roles import ROLES_TO_SCOPES_MAPPING
from fides.api.oauth.utils import verify_oauth_client
from fides.api.schemas.client import ClientCreatedResponse
@@ -98,6 +99,11 @@ async def acquire_access_token(
if not client_detail.credentials_valid(client_secret):
raise AuthenticationFailure(detail="Authentication Failure")
+ if basic_credentials:
+ user = FidesUser.get_by(db, field="username", value=basic_credentials.username)
+ if user and user.disabled: # TODO: Revoke existing session if disabled.
+ raise AuthenticationFailure(detail="Authentication Failure")
+
logger.info("Creating access token")
access_code = client_detail.create_access_code_jwe(
diff --git a/src/fides/api/api/v1/endpoints/user_endpoints.py b/src/fides/api/api/v1/endpoints/user_endpoints.py
index 98305d4554..6b75115743 100644
--- a/src/fides/api/api/v1/endpoints/user_endpoints.py
+++ b/src/fides/api/api/v1/endpoints/user_endpoints.py
@@ -23,13 +23,14 @@
)
from fides.api.api import deps
-from fides.api.api.deps import get_db
+from fides.api.api.deps import get_config_proxy, get_db
from fides.api.api.v1.endpoints.user_permission_endpoints import validate_user_id
-from fides.api.common_exceptions import AuthenticationError, AuthorizationError
+from fides.api.common_exceptions import AuthenticationError
from fides.api.cryptography.cryptographic_util import b64_str_to_str
from fides.api.cryptography.schemas.jwt import JWE_PAYLOAD_CLIENT_ID
from fides.api.models.client import ClientDetail
from fides.api.models.fides_user import FidesUser
+from fides.api.models.fides_user_invite import FidesUserInvite
from fides.api.models.fides_user_permissions import FidesUserPermissions
from fides.api.models.sql_models import System # type: ignore[attr-defined]
from fides.api.oauth.roles import APPROVER, VIEWER
@@ -50,6 +51,11 @@
UserResponse,
UserUpdate,
)
+from fides.api.service.user.fides_user_service import (
+ accept_invite,
+ invite_user,
+ perform_login,
+)
from fides.api.util.api_router import APIRouter
from fides.common.api.scope_registry import (
SCOPE_REGISTRY,
@@ -65,6 +71,7 @@
from fides.common.api.v1 import urn_registry as urls
from fides.common.api.v1.urn_registry import V1_URL_PREFIX
from fides.config import CONFIG, FidesConfig, get_config
+from fides.config.config_proxy import ConfigProxy
router = APIRouter(tags=["Users"], prefix=V1_URL_PREFIX)
@@ -407,7 +414,7 @@ def create_user(
*,
db: Session = Depends(get_db),
user_data: UserCreate,
- config: FidesConfig = Depends(get_config),
+ config_proxy: ConfigProxy = Depends(get_config_proxy),
) -> FidesUser:
"""
Create a user given a username and password.
@@ -421,8 +428,8 @@ def create_user(
# The root user is not stored in the database so make sure here that the user name
# is not the same as the root user name.
if (
- config.security.root_username
- and config.security.root_username == user_data.username
+ config_proxy.security.root_username
+ and config_proxy.security.root_username == user_data.username
):
raise HTTPException(
status_code=HTTP_400_BAD_REQUEST, detail="Username already exists."
@@ -435,7 +442,19 @@ def create_user(
status_code=HTTP_400_BAD_REQUEST, detail="Username already exists."
)
+ user = FidesUser.get_by(db, field="email_address", value=user_data.email_address)
+
+ if user:
+ raise HTTPException(
+ status_code=HTTP_400_BAD_REQUEST,
+ detail="User with this email address already exists.",
+ )
+
user = FidesUser.create(db=db, data=user_data.dict())
+
+ # invite user via email
+ invite_user(db=db, config_proxy=config_proxy, user=user)
+
logger.info("Created user with id: '{}'.", user.id)
FidesUserPermissions.create(
db=db,
@@ -591,39 +610,64 @@ def user_login(
)
-def perform_login(
- db: Session,
- client_id_byte_length: int,
- client_secret_btye_length: int,
- user: FidesUser,
-) -> ClientDetail:
- """Performs a login by updating the FidesUser instance and creating and returning
- an associated ClientDetail.
+def verify_invite_code(
+ username: str,
+ invite_code: str,
+ db: Session = Depends(get_db),
+) -> FidesUserInvite:
+ """
+ Security dependency to verify the invite code.
+ Returns the validated FidesUserInvite if all the checks pass.
"""
+ user_invite = FidesUserInvite.get_by(db, field="username", value=username)
- client = user.client
- if not client:
- logger.info("Creating client for login")
- client, _ = ClientDetail.create_client_and_secret(
- db,
- client_id_byte_length,
- client_secret_btye_length,
- scopes=[], # type: ignore
- roles=user.permissions.roles, # type: ignore
- systems=user.system_ids, # type: ignore
- user_id=user.id,
+ if not user_invite:
+ raise HTTPException(
+ status_code=HTTP_404_NOT_FOUND,
+ detail="User not found.",
+ )
+
+ if not user_invite.invite_code_valid(invite_code):
+ raise HTTPException(
+ status_code=HTTP_400_BAD_REQUEST,
+ detail="Invite code is invalid.",
+ )
+
+ if user_invite.is_expired():
+ raise HTTPException(
+ status_code=HTTP_400_BAD_REQUEST,
+ detail="Invite code has expired.",
)
- else:
- # Refresh the client just in case - for example, scopes and roles were added via the db directly.
- client.roles = user.permissions.roles # type: ignore
- client.systems = user.system_ids # type: ignore
- client.save(db)
- if not user.permissions.roles and not user.systems: # type: ignore
- logger.warning("User {} needs roles or systems to login.", user.id)
- raise AuthorizationError(detail="Not Authorized for this action")
+ return user_invite
- user.last_login_at = datetime.utcnow()
- user.save(db)
- return client
+@router.post(
+ urls.USER_ACCEPT_INVITE,
+)
+def accept_user_invite(
+ *,
+ db: Session = Depends(get_db),
+ config: FidesConfig = Depends(get_config),
+ user_data: UserForcePasswordReset,
+ verified_invite: FidesUserInvite = Depends(verify_invite_code),
+) -> UserLoginResponse:
+ """Sets the password and enables the user if a valid username and invite code are provided."""
+
+ user: Optional[FidesUser] = FidesUser.get_by(
+ db=db, field="username", value=verified_invite.username
+ )
+ if not user:
+ raise HTTPException(
+ status_code=HTTP_404_NOT_FOUND,
+ detail=f"User with username {verified_invite.username} does not exist.",
+ )
+
+ user, access_code = accept_invite(
+ db=db, config=config, user=user, new_password=user_data.new_password
+ )
+
+ return UserLoginResponse(
+ user_data=user,
+ token_data=AccessToken(access_token=access_code),
+ )
diff --git a/src/fides/api/db/base.py b/src/fides/api/db/base.py
index 8d2cebed93..85ad17f4ec 100644
--- a/src/fides/api/db/base.py
+++ b/src/fides/api/db/base.py
@@ -14,6 +14,7 @@
from fides.api.models.experience_notices import ExperienceNotices
from fides.api.models.fides_cloud import FidesCloud
from fides.api.models.fides_user import FidesUser
+from fides.api.models.fides_user_invite import FidesUserInvite
from fides.api.models.fides_user_permissions import FidesUserPermissions
from fides.api.models.location_regulation_selections import LocationRegulationSelections
from fides.api.models.manual_webhook import AccessManualWebhook
diff --git a/src/fides/api/email_templates/get_email_template.py b/src/fides/api/email_templates/get_email_template.py
index 87606a292a..916d59657a 100644
--- a/src/fides/api/email_templates/get_email_template.py
+++ b/src/fides/api/email_templates/get_email_template.py
@@ -16,6 +16,7 @@
PRIVACY_REQUEST_REVIEW_DENY_TEMPLATE,
SUBJECT_IDENTITY_VERIFICATION_TEMPLATE,
TEST_MESSAGE_TEMPLATE,
+ USER_INVITE,
)
from fides.api.schemas.messaging.messaging import MessagingActionType
@@ -53,6 +54,8 @@ def get_email_template( # pylint: disable=too-many-return-statements
return template_env.get_template(PRIVACY_REQUEST_REVIEW_APPROVE_TEMPLATE)
if action_type == MessagingActionType.TEST_MESSAGE:
return template_env.get_template(TEST_MESSAGE_TEMPLATE)
+ if action_type == MessagingActionType.USER_INVITE:
+ return template_env.get_template(USER_INVITE)
logger.error("No corresponding template linked to the {}", action_type)
raise EmailTemplateUnhandledActionType(
diff --git a/src/fides/api/email_templates/template_names.py b/src/fides/api/email_templates/template_names.py
index e8cadf773b..83d80a6a77 100644
--- a/src/fides/api/email_templates/template_names.py
+++ b/src/fides/api/email_templates/template_names.py
@@ -9,3 +9,4 @@
PRIVACY_REQUEST_REVIEW_DENY_TEMPLATE = "privacy_request_review_deny.html"
PRIVACY_REQUEST_REVIEW_APPROVE_TEMPLATE = "privacy_request_review_approve.html"
TEST_MESSAGE_TEMPLATE = "test_message.html"
+USER_INVITE = "user_invite.html"
diff --git a/src/fides/api/email_templates/templates/user_invite.html b/src/fides/api/email_templates/templates/user_invite.html
new file mode 100644
index 0000000000..5f6d2fe18d
--- /dev/null
+++ b/src/fides/api/email_templates/templates/user_invite.html
@@ -0,0 +1,12 @@
+
+
+
+
+ Welcome to Fides
+
+
+
+ You've been invited to join Fides, click here to accept the invite and setup your account.
+
+
+
diff --git a/src/fides/api/models/fides_user.py b/src/fides/api/models/fides_user.py
index e49e1e7383..72a1f3e03a 100644
--- a/src/fides/api/models/fides_user.py
+++ b/src/fides/api/models/fides_user.py
@@ -1,11 +1,14 @@
# pylint: disable=unused-import
from __future__ import annotations
+import uuid
from datetime import datetime
from typing import TYPE_CHECKING, Any, List
from citext import CIText
-from sqlalchemy import Column, DateTime, String
+from sqlalchemy import Boolean, Column, DateTime
+from sqlalchemy import Enum as EnumColumn
+from sqlalchemy import String
from sqlalchemy.orm import Session, relationship
from fides.api.common_exceptions import SystemManagerException
@@ -15,6 +18,7 @@
# Intentionally importing SystemManager here to build the FidesUser.systems relationship
from fides.api.models.system_manager import SystemManager # type: ignore[unused-import]
+from fides.api.schemas.user import DisabledReason
if TYPE_CHECKING:
from fides.api.models.sql_models import System # type: ignore[attr-defined]
@@ -24,10 +28,13 @@ class FidesUser(Base):
"""The DB ORM model for FidesUser."""
username = Column(CIText, unique=True, index=True)
+ email_address = Column(CIText, unique=True, nullable=True)
first_name = Column(String, nullable=True)
last_name = Column(String, nullable=True)
hashed_password = Column(String, nullable=False)
salt = Column(String, nullable=False)
+ disabled = Column(Boolean, nullable=False, server_default="f")
+ disabled_reason = Column(EnumColumn(DisabledReason), nullable=True)
last_login_at = Column(DateTime(timezone=True), nullable=True)
password_reset_at = Column(DateTime(timezone=True), nullable=True)
@@ -68,7 +75,12 @@ def create(
) -> FidesUser:
"""Create a FidesUser by hashing the password with a generated salt
and storing the hashed password and the salt"""
- hashed_password, salt = FidesUser.hash_password(data["password"])
+
+ # we set a dummy password if one isn't provided because this means it's part of the user
+ # invite flow and the password will be set by the user after they accept their invite
+ hashed_password, salt = FidesUser.hash_password(
+ data.get("password") or str(uuid.uuid4())
+ )
user = super().create(
db,
@@ -76,8 +88,11 @@ def create(
"salt": salt,
"hashed_password": hashed_password,
"username": data["username"],
+ "email_address": data.get("email_address"),
"first_name": data.get("first_name"),
"last_name": data.get("last_name"),
+ "disabled": data.get("disabled") or False,
+ "disabled_reason": data.get("disabled_reason"),
},
check_name=check_name,
)
diff --git a/src/fides/api/models/fides_user_invite.py b/src/fides/api/models/fides_user_invite.py
new file mode 100644
index 0000000000..a04c4c05c5
--- /dev/null
+++ b/src/fides/api/models/fides_user_invite.py
@@ -0,0 +1,92 @@
+from __future__ import annotations
+
+from datetime import datetime, timedelta, timezone
+from typing import Any
+
+from citext import CIText
+from sqlalchemy import Column, ForeignKey, String
+from sqlalchemy.ext.declarative import declared_attr
+from sqlalchemy.orm import Session
+
+from fides.api.cryptography.cryptographic_util import generate_salt, hash_with_salt
+from fides.api.db.base_class import Base
+
+INVITE_CODE_TTL_HOURS = 24
+
+
+class FidesUserInvite(Base):
+ @declared_attr
+ def __tablename__(self) -> str:
+ return "fides_user_invite"
+
+ username = Column( # type: ignore
+ CIText,
+ ForeignKey("fidesuser.username", ondelete="CASCADE"),
+ primary_key=True,
+ )
+ hashed_invite_code = Column(String, nullable=False)
+ salt = Column(String, nullable=False)
+
+ @classmethod
+ def hash_invite_code(
+ cls, invite_code: str, encoding: str = "UTF-8"
+ ) -> tuple[str, str]:
+ """Utility function to hash a user's invite code with a generated salt."""
+
+ salt = generate_salt()
+ hashed_invite_code = hash_with_salt(
+ invite_code.encode(encoding),
+ salt.encode(encoding),
+ )
+ return hashed_invite_code, salt
+
+ @classmethod
+ def create(
+ cls, db: Session, *, data: dict[str, Any], check_name: bool = False
+ ) -> FidesUserInvite:
+ """
+ Create a FidesUserInvite by hashing the invite code with a generated salt
+ and storing the hashed invite code and the salt.
+ """
+
+ hashed_invite_code, salt = FidesUserInvite.hash_invite_code(data["invite_code"])
+
+ user_invite = super().create(
+ db,
+ data={
+ "username": data["username"],
+ "hashed_invite_code": hashed_invite_code,
+ "salt": salt,
+ },
+ )
+
+ return user_invite
+
+ def invite_code_valid(self, invite_code: str, encoding: str = "UTF-8") -> bool:
+ """Verifies that the provided invite code is correct."""
+
+ invite_code_hash = hash_with_salt(
+ invite_code.encode(encoding),
+ self.salt.encode(encoding),
+ )
+
+ return invite_code_hash == self.hashed_invite_code
+
+ def is_expired(self) -> bool:
+ """Check if the invite has expired."""
+
+ current_time_utc = datetime.now(timezone.utc)
+
+ if not self.updated_at:
+ return True
+
+ expiration_datetime = self.updated_at + timedelta(hours=INVITE_CODE_TTL_HOURS)
+ return current_time_utc > expiration_datetime
+
+ def renew_invite(self, db: Session, new_invite_code: str) -> None:
+ """Updates the invite code and extends the expiration."""
+
+ hashed_invite_code, salt = FidesUserInvite.hash_invite_code(new_invite_code)
+ self.hashed_invite_code = hashed_invite_code
+ self.salt = salt
+ self.save(db)
diff --git a/src/fides/api/schemas/application_config.py b/src/fides/api/schemas/application_config.py
index 4c16897c6b..5f00ef7cc4 100644
--- a/src/fides/api/schemas/application_config.py
+++ b/src/fides/api/schemas/application_config.py
@@ -70,6 +70,14 @@ class Config:
extra = Extra.forbid
+class AdminUIConfig(FidesSchema):
+ enabled: Optional[bool]
+ url: Optional[str]
+
+ class Config:
+ extra = Extra.forbid
+
+
class ConsentConfig(FidesSchema):
override_vendor_purposes: Optional[bool]
@@ -105,6 +113,7 @@ class ApplicationConfig(FidesSchema):
execution: Optional[ExecutionApplicationConfig]
security: Optional[SecurityApplicationConfig]
consent: Optional[ConsentConfig]
+ admin_ui: Optional[AdminUIConfig]
@root_validator(pre=True)
def validate_not_empty(cls, values: Dict) -> Dict:
diff --git a/src/fides/api/schemas/messaging/messaging.py b/src/fides/api/schemas/messaging/messaging.py
index 97b042222c..121e36752c 100644
--- a/src/fides/api/schemas/messaging/messaging.py
+++ b/src/fides/api/schemas/messaging/messaging.py
@@ -63,6 +63,7 @@ class MessagingActionType(str, Enum):
PRIVACY_REQUEST_COMPLETE_DELETION = "privacy_request_complete_deletion"
PRIVACY_REQUEST_REVIEW_DENY = "privacy_request_review_deny"
PRIVACY_REQUEST_REVIEW_APPROVE = "privacy_request_review_approve"
+ USER_INVITE = "user_invite"
TEST_MESSAGE = "test_message"
@@ -165,6 +166,13 @@ class ErasureRequestBodyParams(BaseModel):
identities: List[str]
+class UserInviteBodyParams(BaseModel):
+ """Body params required to send a user invite email"""
+
+ username: str
+ invite_code: str
+
+
class FidesopsMessage(
BaseModel,
smart_union=True,
@@ -451,6 +459,10 @@ class BulkPutBasicMessagingTemplateResponse(BulkResponse):
failed: List[BulkUpdateFailed]
+class UserEmailInviteStatus(BaseModel):
+ enabled: bool
+
+
class MessagingTemplateWithPropertiesBase(BaseModel):
id: str
type: str
diff --git a/src/fides/api/schemas/user.py b/src/fides/api/schemas/user.py
index 84f1ce4c2d..ad0d238685 100644
--- a/src/fides/api/schemas/user.py
+++ b/src/fides/api/schemas/user.py
@@ -1,8 +1,9 @@
import re
from datetime import datetime
+from enum import Enum
from typing import Optional
-from pydantic import validator
+from pydantic import EmailStr, validator
from fides.api.cryptography.cryptographic_util import decode_password
from fides.api.schemas.base_class import FidesSchema
@@ -20,14 +21,16 @@ class UserCreate(FidesSchema):
"""Data required to create a FidesUser."""
username: str
- password: str
+ password: Optional[str]
+ email_address: EmailStr
first_name: Optional[str]
last_name: Optional[str]
+ disabled: bool = False
@validator("username")
@classmethod
def validate_username(cls, username: str) -> str:
- """Ensure password does not have spaces."""
+ """Ensure username does not have spaces."""
if " " in username:
raise ValueError("Usernames cannot have spaces.")
return username
@@ -79,8 +82,11 @@ class UserResponse(FidesSchema):
id: str
username: str
created_at: datetime
+ email_address: Optional[EmailStr]
first_name: Optional[str]
last_name: Optional[str]
+ disabled: Optional[bool] = False
+ disabled_reason: Optional[str]
class UserLoginResponse(FidesSchema):
@@ -104,7 +110,14 @@ class UserForcePasswordReset(FidesSchema):
class UserUpdate(FidesSchema):
- """Data required to update a FidesopsUser"""
+ """Data required to update a FidesUser"""
+ email_address: Optional[EmailStr]
first_name: Optional[str]
last_name: Optional[str]
+
+
+class DisabledReason(Enum):
+ """Reasons for why a user is disabled"""
+
+ pending_invite = "pending_invite"
diff --git a/src/fides/api/service/messaging/message_dispatch_service.py b/src/fides/api/service/messaging/message_dispatch_service.py
index 8e2ba18f45..47e2b0e138 100644
--- a/src/fides/api/service/messaging/message_dispatch_service.py
+++ b/src/fides/api/service/messaging/message_dispatch_service.py
@@ -40,6 +40,7 @@
RequestReceiptBodyParams,
RequestReviewDenyBodyParams,
SubjectIdentityVerificationBodyParams,
+ UserInviteBodyParams,
)
from fides.api.schemas.redis_cache import Identity
from fides.api.service.connectors.base_email_connector import (
@@ -220,6 +221,7 @@ def dispatch_message(
RequestReceiptBodyParams,
RequestReviewDenyBodyParams,
ErasureRequestBodyParams,
+ UserInviteBodyParams,
]
] = None,
subject_override: Optional[str] = None,
@@ -270,8 +272,11 @@ def dispatch_message(
db=db, template_type=action_type.value
)
+ config_proxy = ConfigProxy(db=db)
+
if messaging_method == MessagingMethod.EMAIL:
message = _build_email(
+ config_proxy=config_proxy,
action_type=action_type,
body_params=message_body_params,
messaging_template=messaging_template,
@@ -384,6 +389,7 @@ def _render(template_str: str, variables: Optional[Dict] = None) -> str:
def _build_email( # pylint: disable=too-many-return-statements
+ config_proxy: ConfigProxy,
action_type: MessagingActionType,
body_params: Any,
messaging_template: Optional[MessagingTemplate] = None,
@@ -482,6 +488,18 @@ def _build_email( # pylint: disable=too-many-return-statements
return EmailForActionType(
subject="Test message from fides", body=base_template.render()
)
+ if action_type == MessagingActionType.USER_INVITE:
+ base_template = get_email_template(action_type)
+ return EmailForActionType(
+ subject="Welcome to Fides",
+ body=base_template.render(
+ {
+ "admin_ui_url": config_proxy.admin_ui.url,
+ "username": body_params.username,
+ "invite_code": body_params.invite_code,
+ }
+ ),
+ )
logger.error("Message action type {} is not implemented", action_type)
raise MessageDispatchException(
f"Message action type {action_type} is not implemented"
diff --git a/src/fides/api/service/user/__init__.py b/src/fides/api/service/user/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/src/fides/api/service/user/fides_user_service.py b/src/fides/api/service/user/fides_user_service.py
new file mode 100644
index 0000000000..0b6524558a
--- /dev/null
+++ b/src/fides/api/service/user/fides_user_service.py
@@ -0,0 +1,120 @@
+import uuid
+from datetime import datetime
+from typing import Tuple
+
+from loguru import logger
+from sqlalchemy.orm import Session
+
+from fides.api.api.v1.endpoints.messaging_endpoints import user_email_invite_status
+from fides.api.common_exceptions import AuthorizationError
+from fides.api.models.client import ClientDetail
+from fides.api.models.fides_user import FidesUser
+from fides.api.models.fides_user_invite import FidesUserInvite
+from fides.api.schemas.messaging.messaging import (
+ MessagingActionType,
+ UserInviteBodyParams,
+)
+from fides.api.schemas.redis_cache import Identity
+from fides.api.service.messaging.message_dispatch_service import dispatch_message
+from fides.config import FidesConfig
+from fides.config.config_proxy import ConfigProxy
+
+
+def invite_user(db: Session, config_proxy: ConfigProxy, user: FidesUser) -> None:
+ """
+ Generates a user invite and sends the invite code to the user via email.
+
+ This is a no-op if email messaging isn't configured.
+ """
+
+ # invite user via email if email messaging is enabled and the Admin UI URL is defined
+ if user_email_invite_status(db=db, config_proxy=config_proxy).enabled:
+ invite_code = str(uuid.uuid4())
+ FidesUserInvite.create(
+ db=db, data={"username": user.username, "invite_code": invite_code}
+ )
+ user.update(db, data={"disabled": True})
+ dispatch_message(
+ db,
+ action_type=MessagingActionType.USER_INVITE,
+ to_identity=Identity(email=user.email_address),
+ service_type=config_proxy.notifications.notification_service_type,
+ message_body_params=UserInviteBodyParams(
+ username=user.username, invite_code=invite_code
+ ),
+ )
+
+
+def accept_invite(
+ db: Session, config: FidesConfig, user: FidesUser, new_password: str
+) -> Tuple[FidesUser, str]:
+ """
+ Updates the user password and enables the user. Also removes the user invite from the database.
+ Returns a tuple of the updated user and their access code.
+ """
+
+ # update password and enable
+ user.update_password(db=db, new_password=new_password)
+ user.update(
+ db,
+ data={"disabled": False, "disabled_reason": None},
+ )
+ db.refresh(user)
+
+ # delete invite
+ if user.username:
+ invite = FidesUserInvite.get_by(db=db, field="username", value=user.username)
+ if invite:
+ invite.delete(db)
+ else:
+ logger.warning("Username is missing, skipping invite deletion.")
+
+ client = perform_login(
+ db,
+ config.security.oauth_client_id_length_bytes,
+ config.security.oauth_client_secret_length_bytes,
+ user,
+ )
+
+ logger.info("Creating login access token")
+ access_code = client.create_access_code_jwe(config.security.app_encryption_key)
+
+ return user, access_code
+
+
+def perform_login(
+ db: Session,
+ client_id_byte_length: int,
+ client_secret_byte_length: int,
+ user: FidesUser,
+) -> ClientDetail:
+ """Performs a login by updating the FidesUser instance and creating and returning
+ an associated ClientDetail.
+ """
+
+ client = user.client
+ if not client:
+ logger.info("Creating client for login")
+ client, _ = ClientDetail.create_client_and_secret(
+ db,
+ client_id_byte_length,
+ client_secret_byte_length,
+ scopes=[], # type: ignore
+ roles=user.permissions.roles, # type: ignore
+ systems=user.system_ids, # type: ignore
+ user_id=user.id,
+ )
+ else:
+ # Refresh the client just in case - for example, scopes and roles were added via the db directly.
+ client.roles = user.permissions.roles # type: ignore
+ client.systems = user.system_ids # type: ignore
+ client.save(db)
+
+ if not user.permissions.roles and not user.systems: # type: ignore
+ logger.warning("User {} needs roles or systems to login.", user.id)
+ raise AuthorizationError(detail="Not Authorized for this action")
+
+ user.last_login_at = datetime.utcnow()
+ user.save(db)
+
+ return client
diff --git a/src/fides/cli/commands/user.py b/src/fides/cli/commands/user.py
index 977d9ddf35..9163a8b027 100644
--- a/src/fides/cli/commands/user.py
+++ b/src/fides/cli/commands/user.py
@@ -1,8 +1,10 @@
"""Contains the user command group for the fides CLI."""
import rich_click as click
+from pydantic import EmailStr
from fides.cli.options import (
+ email_address_argument,
first_name_option,
last_name_option,
password_argument,
@@ -25,10 +27,16 @@ def user(ctx: click.Context) -> None:
@click.pass_context
@username_argument
@password_argument
+@email_address_argument
@first_name_option
@last_name_option
def create(
- ctx: click.Context, username: str, password: str, first_name: str, last_name: str
+ ctx: click.Context,
+ username: str,
+ password: str,
+ email_address: EmailStr,
+ first_name: str,
+ last_name: str,
) -> None:
"""
Use the credentials file to create a new user. Gives full permissions to the new user.
@@ -38,6 +46,7 @@ def create(
create_command(
username=username,
password=password,
+ email_address=email_address,
first_name=first_name,
last_name=last_name,
server_url=server_url,
diff --git a/src/fides/cli/options.py b/src/fides/cli/options.py
index d0cacdd1ed..15ad01597b 100644
--- a/src/fides/cli/options.py
+++ b/src/fides/cli/options.py
@@ -330,3 +330,8 @@ def username_argument(command: Callable) -> Callable:
def password_argument(command: Callable) -> Callable:
command = click.argument("password", type=str)(command) # type: ignore
return command
+
+
+def email_address_argument(command: Callable) -> Callable:
+ command = click.argument("email_address", type=str)(command) # type: ignore
+ return command
diff --git a/src/fides/common/api/v1/urn_registry.py b/src/fides/common/api/v1/urn_registry.py
index a97cf17a84..0c87435a75 100644
--- a/src/fides/common/api/v1/urn_registry.py
+++ b/src/fides/common/api/v1/urn_registry.py
@@ -67,6 +67,7 @@
MESSAGING_DEFAULT_SECRETS = "/messaging/default/{service_type}/secret"
MESSAGING_DEFAULT_BY_TYPE = "/messaging/default/{service_type}"
MESSAGING_TEST = "/messaging/config/test"
+MESSAGING_EMAIL_INVITE_STATUS = "/messaging/email-invite/status"
# Policy URLs
POLICY_LIST = "/dsr/policy"
@@ -174,6 +175,7 @@
# User URLs
USERS = "/user"
+USER_ACCEPT_INVITE = "/user/accept-invite"
USER_DETAIL = "/user/{user_id}"
USER_PASSWORD_RESET = "/user/{user_id}/reset-password"
USER_FORCE_PASSWORD_RESET = "/user/{user_id}/force-reset-password"
diff --git a/src/fides/config/admin_ui_settings.py b/src/fides/config/admin_ui_settings.py
index 0f5cf0804a..569f9547eb 100644
--- a/src/fides/config/admin_ui_settings.py
+++ b/src/fides/config/admin_ui_settings.py
@@ -1,4 +1,6 @@
-from pydantic import Field
+from typing import Optional
+
+from pydantic import AnyHttpUrl, Field
from .fides_settings import FidesSettings
@@ -9,6 +11,9 @@ class AdminUISettings(FidesSettings):
enabled: bool = Field(
default=True, description="Toggle whether the Admin UI is served."
)
+ url: Optional[AnyHttpUrl] = Field(
+ default=None, description="The base URL for the Admin UI."
+ )
class Config:
env_prefix = "FIDES__ADMIN_UI__"
diff --git a/src/fides/config/config_proxy.py b/src/fides/config/config_proxy.py
index e66e201239..22c20eecc2 100644
--- a/src/fides/config/config_proxy.py
+++ b/src/fides/config/config_proxy.py
@@ -82,6 +82,13 @@ def __getattribute__(self, __name: str) -> Any:
)
+class AdminUISettingsProxy(ConfigProxyBase):
+ prefix = "admin_ui"
+
+ enabled: bool
+ url: Optional[str]
+
+
class NotificationSettingsProxy(ConfigProxyBase):
prefix = "notifications"
@@ -163,6 +170,7 @@ class ConfigProxy:
"""
def __init__(self, db: Session) -> None:
+ self.admin_ui = AdminUISettingsProxy(db)
self.notifications = NotificationSettingsProxy(db)
self.execution = ExecutionSettingsProxy(db)
self.storage = StorageSettingsProxy(db)
diff --git a/src/fides/config/utils.py b/src/fides/config/utils.py
index ae6e7b063e..6dcc49a013 100644
--- a/src/fides/config/utils.py
+++ b/src/fides/config/utils.py
@@ -66,4 +66,5 @@ def get_dev_mode() -> bool:
"active_default_storage_type",
],
"consent": ["override_vendor_purposes"],
+ "admin-ui": ["enabled", "url"],
}
diff --git a/src/fides/core/user.py b/src/fides/core/user.py
index 78b136b458..f936322b19 100644
--- a/src/fides/core/user.py
+++ b/src/fides/core/user.py
@@ -5,6 +5,7 @@
import requests
from fideslang.validation import FidesKey
+from pydantic import EmailStr
from fides.api.cryptography.cryptographic_util import str_to_b64_str
from fides.common.utils import echo_green, echo_red, handle_cli_response
@@ -42,6 +43,7 @@ def get_access_token(username: str, password: str, server_url: str) -> Tuple[str
def create_user(
username: str,
password: str,
+ email_address: EmailStr,
first_name: str,
last_name: str,
auth_header: Dict[str, str],
@@ -51,6 +53,7 @@ def create_user(
request_data = {
"username": username,
"password": str_to_b64_str(password),
+ "email_address": email_address,
"first_name": first_name,
"last_name": last_name,
}
@@ -120,7 +123,12 @@ def update_user_permissions(
def create_command(
- username: str, password: str, first_name: str, last_name: str, server_url: str
+ username: str,
+ password: str,
+ email_address: EmailStr,
+ first_name: str,
+ last_name: str,
+ server_url: str,
) -> None:
"""
Given new user information, create a new user via the API using
@@ -131,6 +139,7 @@ def create_command(
user_response = create_user(
username=username,
password=password,
+ email_address=email_address,
first_name=first_name,
last_name=last_name,
auth_header=auth_header,
diff --git a/tests/conftest.py b/tests/conftest.py
index c9767bda99..d1855a565c 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -276,6 +276,7 @@ def application_user(db, oauth_client):
data={
"username": unique_username,
"password": "test_password",
+ "email_address": f"{unique_username}@ethyca.com",
"first_name": "Test",
"last_name": "User",
},
@@ -292,6 +293,7 @@ def user(db):
data={
"username": "test_fidesops_user",
"password": "TESTdcnG@wzJeu0&%3Qe2fGo7",
+ "email_address": "fides.user@ethyca.com",
},
)
client = ClientDetail(
@@ -1071,6 +1073,7 @@ def owner_user(db):
data={
"username": "test_fides_owner_user",
"password": "TESTdcnG@wzJeu0&%3Qe2fGo7",
+ "email_address": "owner.user@ethyca.com",
},
)
client = ClientDetail(
@@ -1097,6 +1100,7 @@ def approver_user(db):
data={
"username": "test_fides_viewer_user",
"password": "TESTdcnG@wzJeu0&%3Qe2fGo7",
+ "email_address": "approver.user@ethyca.com",
},
)
client = ClientDetail(
@@ -1123,6 +1127,7 @@ def viewer_user(db):
data={
"username": "test_fides_viewer_user",
"password": "TESTdcnG@wzJeu0&%3Qe2fGo7",
+ "email_address": "viewer2.user@ethyca.com",
},
)
client = ClientDetail(
@@ -1148,6 +1153,7 @@ def contributor_user(db):
data={
"username": "test_fides_contributor_user",
"password": "TESTdcnG@wzJeu0&%3Qe2fGo7",
+ "email_address": "contributor.user@ethyca.com",
},
)
client = ClientDetail(
@@ -1176,6 +1182,7 @@ def viewer_and_approver_user(db):
data={
"username": "test_fides_viewer_and_approver_user",
"password": "TESTdcnG@wzJeu0&%3Qe2fGo7",
+ "email_address": "viewerapprover.user@ethyca.com",
},
)
client = ClientDetail(
diff --git a/tests/ctl/cli/test_cli.py b/tests/ctl/cli/test_cli.py
index 0762c9ab59..ca3339b497 100644
--- a/tests/ctl/cli/test_cli.py
+++ b/tests/ctl/cli/test_cli.py
@@ -1036,6 +1036,7 @@ def test_user_create(
"create",
"newuser",
"Newpassword1!",
+ "test@ethyca.com",
],
env={"FIDES_CREDENTIALS_PATH": credentials_path},
)
diff --git a/tests/fixtures/application_fixtures.py b/tests/fixtures/application_fixtures.py
index a85e288f07..ca35d24fa7 100644
--- a/tests/fixtures/application_fixtures.py
+++ b/tests/fixtures/application_fixtures.py
@@ -2479,6 +2479,7 @@ def application_user(
data={
"username": unique_username,
"password": "test_password",
+ "email_address": "test.user@ethyca.com",
"first_name": "Test",
"last_name": "User",
},
@@ -2551,6 +2552,7 @@ def system_manager(db: Session, system) -> System:
data={
"username": "test_system_manager_user",
"password": "TESTdcnG@wzJeu0&%3Qe2fGo7",
+ "email_address": "system-manager.user@ethyca.com",
},
)
client = ClientDetail(
diff --git a/tests/lib/test_oauth_schemas_user.py b/tests/lib/test_oauth_schemas_user.py
index 37ea08f752..b6a196103e 100644
--- a/tests/lib/test_oauth_schemas_user.py
+++ b/tests/lib/test_oauth_schemas_user.py
@@ -1,6 +1,7 @@
# pylint: disable=missing-function-docstring
import pytest
+from pydantic import ValidationError
from fides.api.cryptography.cryptographic_util import str_to_b64_str
from fides.api.schemas.user import UserCreate, UserLogin
@@ -21,6 +22,7 @@ def test_bad_password(password, message):
UserCreate(
username="test",
password=str_to_b64_str(password),
+ email_address="test.user@ethyca.com",
first_name="test",
last_name="test",
)
@@ -33,6 +35,18 @@ def test_user_create_user_name_with_spaces():
UserCreate(
username="some user",
password=str_to_b64_str("Testtest1!"),
+ email_address="test.user@ethyca.com",
+ first_name="test",
+ last_name="test",
+ )
+
+
+def test_user_create_invalid_email():
+ with pytest.raises(ValidationError):
+ UserCreate(
+ username="user",
+ password=str_to_b64_str("Testtest1!"),
+ email_address="NotAnEmailAddress",
first_name="test",
last_name="test",
)
@@ -49,6 +63,7 @@ def test_user_create(password, expected):
user = UserCreate(
username="immauser",
password=password,
+ email_address="test.user@ethyca.com",
first_name="imma",
last_name="user",
)
diff --git a/tests/ops/api/v1/endpoints/test_user_endpoints.py b/tests/ops/api/v1/endpoints/test_user_endpoints.py
index 59bf23021a..0833b09ad9 100644
--- a/tests/ops/api/v1/endpoints/test_user_endpoints.py
+++ b/tests/ops/api/v1/endpoints/test_user_endpoints.py
@@ -1,5 +1,6 @@
import json
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, timezone
+from unittest import mock
from uuid import uuid4
import pytest
@@ -25,6 +26,7 @@
)
from fides.api.models.client import ClientDetail
from fides.api.models.fides_user import FidesUser
+from fides.api.models.fides_user_invite import INVITE_CODE_TTL_HOURS, FidesUserInvite
from fides.api.models.fides_user_permissions import FidesUserPermissions
from fides.api.models.sql_models import PrivacyDeclaration, System
from fides.api.oauth.jwt import generate_jwe
@@ -46,6 +48,7 @@
from fides.common.api.v1.urn_registry import (
LOGIN,
LOGOUT,
+ USER_ACCEPT_INVITE,
USER_DETAIL,
USERS,
V1_URL_PREFIX,
@@ -80,6 +83,7 @@ def test_create_user_bad_username(
body = {
"username": "spaces in name",
"password": str_to_b64_str("TestP@ssword9"),
+ "email_address": "test.user@ethyca.com",
}
response = api_client.post(url, headers=auth_header, json=body)
@@ -94,7 +98,12 @@ def test_username_exists(
) -> None:
auth_header = generate_auth_header([USER_CREATE])
- body = {"username": "test_user", "password": str_to_b64_str("TestP@ssword9")}
+ body = {
+ "username": "test_user",
+ "password": str_to_b64_str("TestP@ssword9"),
+ "email_address": "test.user@ethyca.com",
+ }
+
FidesUser.create(db=db, data=body)
response = api_client.post(url, headers=auth_header, json=body)
@@ -110,7 +119,11 @@ def test_create_user_bad_password(
) -> None:
auth_header = generate_auth_header([USER_CREATE])
- body = {"username": "test_user", "password": str_to_b64_str("short")}
+ body = {
+ "username": "test_user",
+ "password": str_to_b64_str("short"),
+ "email_address": "test.user@ethyca.com",
+ }
response = api_client.post(url, headers=auth_header, json=body)
assert HTTP_422_UNPROCESSABLE_ENTITY == response.status_code
assert (
@@ -118,7 +131,11 @@ def test_create_user_bad_password(
== "Password must have at least eight characters."
)
- body = {"username": "test_user", "password": str_to_b64_str("longerpassword")}
+ body = {
+ "username": "test_user",
+ "password": str_to_b64_str("longerpassword"),
+ "email_address": "test.user@ethyca.com",
+ }
response = api_client.post(url, headers=auth_header, json=body)
assert HTTP_422_UNPROCESSABLE_ENTITY == response.status_code
assert (
@@ -126,7 +143,11 @@ def test_create_user_bad_password(
== "Password must have at least one number."
)
- body = {"username": "test_user", "password": str_to_b64_str("longer55password")}
+ body = {
+ "username": "test_user",
+ "password": str_to_b64_str("longer55password"),
+ "email_address": "test.user@ethyca.com",
+ }
response = api_client.post(url, headers=auth_header, json=body)
assert HTTP_422_UNPROCESSABLE_ENTITY == response.status_code
assert (
@@ -134,7 +155,12 @@ def test_create_user_bad_password(
== "Password must have at least one capital letter."
)
- body = {"username": "test_user", "password": str_to_b64_str("LoNgEr55paSSworD")}
+ body = {
+ "username": "test_user",
+ "password": str_to_b64_str("LoNgEr55paSSworD"),
+ "email_address": "test.user@ethyca.com",
+ }
+
response = api_client.post(url, headers=auth_header, json=body)
assert HTTP_422_UNPROCESSABLE_ENTITY == response.status_code
assert (
@@ -142,6 +168,37 @@ def test_create_user_bad_password(
== "Password must have at least one symbol."
)
+ def test_create_user_no_email(
+ self,
+ api_client,
+ generate_auth_header,
+ url,
+ ) -> None:
+ auth_header = generate_auth_header([USER_CREATE])
+
+ body = {
+ "username": "test_user",
+ "password": str_to_b64_str("TestP@ssword9"),
+ }
+ response = api_client.post(url, headers=auth_header, json=body)
+ assert HTTP_422_UNPROCESSABLE_ENTITY == response.status_code
+
+ def test_create_user_bad_email(
+ self,
+ api_client,
+ generate_auth_header,
+ url,
+ ) -> None:
+ auth_header = generate_auth_header([USER_CREATE])
+
+ body = {
+ "username": "test_user",
+ "password": str_to_b64_str("TestP@ssword9"),
+ "email_address": "not.an.email",
+ }
+ response = api_client.post(url, headers=auth_header, json=body)
+ assert HTTP_422_UNPROCESSABLE_ENTITY == response.status_code
+
def test_create_user(
self,
db,
@@ -150,7 +207,11 @@ def test_create_user(
url,
) -> None:
auth_header = generate_auth_header([USER_CREATE])
- body = {"username": "test_user", "password": str_to_b64_str("TestP@ssword9")}
+ body = {
+ "username": "test_user",
+ "password": str_to_b64_str("TestP@ssword9"),
+ "email_address": "test.user@ethyca.com",
+ }
response = api_client.post(url, headers=auth_header, json=body)
@@ -170,7 +231,11 @@ def test_underscore_in_password(
url,
) -> None:
auth_header = generate_auth_header([USER_CREATE])
- body = {"username": "test_user", "password": str_to_b64_str("Test_passw0rd")}
+ body = {
+ "username": "test_user",
+ "password": str_to_b64_str("Test_passw0rd"),
+ "email_address": "test.user@ethyca.com",
+ }
response = api_client.post(url, headers=auth_header, json=body)
@@ -184,7 +249,11 @@ def test_create_user_as_root(
url,
) -> None:
auth_header = root_auth_header
- body = {"username": "test_user", "password": str_to_b64_str("TestP@ssword9")}
+ body = {
+ "username": "test_user",
+ "password": str_to_b64_str("TestP@ssword9"),
+ "email_address": "test.user@ethyca.com",
+ }
response = api_client.post(url, headers=auth_header, json=body)
@@ -205,6 +274,7 @@ def test_create_user_with_name(
body = {
"username": "test_user",
"password": str_to_b64_str("TestP@ssword9"),
+ "email_address": "test.user@ethyca.com",
"first_name": "Test",
"last_name": "User",
}
@@ -225,7 +295,11 @@ def test_cannot_create_duplicate_user(
url,
) -> None:
auth_header = generate_auth_header([USER_CREATE])
- body = {"username": "test_user", "password": str_to_b64_str("TestP@ssword9")}
+ body = {
+ "username": "test_user",
+ "password": str_to_b64_str("TestP@ssword9"),
+ "email_address": "test.user@ethyca.com",
+ }
response = api_client.post(url, headers=auth_header, json=body)
@@ -238,6 +312,7 @@ def test_cannot_create_duplicate_user(
duplicate_body = {
"username": "TEST_USER",
"password": str_to_b64_str("TestP@ssword9"),
+ "email_address": "test.user1@ethyca.com",
}
response = api_client.post(url, headers=auth_header, json=duplicate_body)
@@ -247,12 +322,46 @@ def test_cannot_create_duplicate_user(
duplicate_body_2 = {
"username": "TEST_user",
"password": str_to_b64_str("TestP@ssword9"),
+ "email_address": "test.user2@ethyca.com",
}
response = api_client.post(url, headers=auth_header, json=duplicate_body_2)
assert HTTP_400_BAD_REQUEST == response.status_code
assert response.json()["detail"] == "Username already exists."
+ def test_cannot_create_duplicate_user_email(
+ self,
+ db,
+ api_client,
+ generate_auth_header,
+ url,
+ ) -> None:
+ auth_header = generate_auth_header([USER_CREATE])
+ body = {
+ "username": "test_user",
+ "password": str_to_b64_str("TestP@ssword9"),
+ "email_address": "test.user@ethyca.com",
+ }
+
+ response = api_client.post(url, headers=auth_header, json=body)
+
+ user = FidesUser.get_by(db, field="username", value=body["username"])
+ assert HTTP_201_CREATED == response.status_code
+ assert response.json() == {"id": user.id}
+ assert user.permissions is not None
+
+ duplicate_body = {
+ "username": "test_user2",
+ "password": str_to_b64_str("TestP@ssword9"),
+ "email_address": "test.user@ethyca.com",
+ }
+
+ response = api_client.post(url, headers=auth_header, json=duplicate_body)
+ assert (
+ response.json()["detail"] == "User with this email address already exists."
+ )
+ assert HTTP_400_BAD_REQUEST == response.status_code
+
class TestDeleteUser:
@pytest.fixture(scope="function")
@@ -280,6 +389,7 @@ def test_delete_self(self, api_client, db):
data={
"username": "test_delete_user",
"password": str_to_b64_str("TESTdcnG@wzJeu0&%3Qe2fGo7"),
+ "email_address": "test2.user@ethyca.com",
},
)
saved_user_id = user.id
@@ -333,6 +443,7 @@ def test_delete_user(self, api_client, db):
data={
"username": "test_delete_user",
"password": str_to_b64_str("TESTdcnG@wzJeu0&%3Qe2fGo7"),
+ "email_address": "test.user@ethyca.com",
},
)
@@ -345,6 +456,7 @@ def test_delete_user(self, api_client, db):
data={
"username": "user_to_delete",
"password": str_to_b64_str("TESTdcnG@wzJeu0&%3Qe2fGo7"),
+ "email_address": "other.user@ethyca.com",
},
)
@@ -408,6 +520,7 @@ def test_delete_user_as_root(self, api_client, db, user, root_auth_header):
data={
"username": "test_delete_user",
"password": str_to_b64_str("TESTdcnG@wzJeu0&%3Qe2fGo7"),
+ "email_address": "test.user@ethyca.com",
},
)
@@ -485,6 +598,7 @@ def test_get_users(self, api_client: TestClient, generate_auth_header, url, db):
data={
"username": f"user{i}",
"password": password,
+ "email_address": f"test{i}.user@ethyca.com",
"first_name": "Test",
"last_name": "User",
},
@@ -507,6 +621,8 @@ def test_get_users(self, api_client: TestClient, generate_auth_header, url, db):
assert user_data["created_at"]
assert user_data["first_name"]
assert user_data["last_name"]
+ assert user_data["email_address"]
+ assert user_data["disabled"] == False
def test_get_filtered_users(
self, api_client: TestClient, generate_auth_header, url, db
@@ -514,7 +630,14 @@ def test_get_filtered_users(
total_users = 50
password = str_to_b64_str("Password123!")
[
- FidesUser.create(db=db, data={"username": f"user{i}", "password": password})
+ FidesUser.create(
+ db=db,
+ data={
+ "username": f"user{i}",
+ "password": password,
+ "email_address": f"test{i}.user@ethyca.com",
+ },
+ )
for i in range(total_users)
]
@@ -1338,6 +1461,7 @@ def test_users_need_permissions_object_before_they_can_be_a_system_manager(
data={
"username": "test_new_user",
"password": "TESTdcnG@wzJeu0&%3Qe2fGo7",
+ "email_address": "test.user@ethyca.com",
},
)
url = V1_URL_PREFIX + f"/user/{new_user.id}/system-manager"
@@ -1359,6 +1483,7 @@ def test_users_need_roles_before_they_can_be_a_system_manager(
data={
"username": "test_new_user",
"password": "TESTdcnG@wzJeu0&%3Qe2fGo7",
+ "email_address": "test.user@ethyca.com",
},
)
@@ -1508,6 +1633,7 @@ def test_get_systems_managed_by_other_user(
data={
"username": "another_user",
"password": "TESTdcnG@wzJeu0&%3Qe2fGo7",
+ "email_address": "test.user@ethyca.com",
},
)
client = ClientDetail(
@@ -1597,6 +1723,7 @@ def test_get_system_managed_by_other_user(
data={
"username": "another_user",
"password": "TESTdcnG@wzJeu0&%3Qe2fGo7",
+ "email_address": "test.user@ethyca.com",
},
)
client = ClientDetail(
@@ -1723,3 +1850,90 @@ def test_delete_user_as_system_manager(
db.refresh(viewer_user)
assert viewer_user.systems == []
+
+
+class TestAcceptUserInvite:
+ @pytest.fixture(scope="function")
+ def url(self) -> str:
+ return V1_URL_PREFIX + USER_ACCEPT_INVITE
+
+ def test_accept_invite_valid(
+ self,
+ db,
+ api_client,
+ url,
+ ):
+ user = FidesUser.create(
+ db=db,
+ data={
+ "username": "valid_user",
+ },
+ )
+ FidesUserPermissions.create(
+ db=db,
+ data={"user_id": user.id, "roles": [VIEWER]},
+ )
+ FidesUserInvite.create(
+ db=db, data={"username": "valid_user", "invite_code": "valid_code"}
+ )
+
+ response = api_client.post(
+ url,
+ params={"username": "valid_user", "invite_code": "valid_code"},
+ json={"username": "valid_user", "new_password": "pass"},
+ )
+
+ assert response.status_code == HTTP_200_OK
+
+ def test_accept_invite_invalid_code(self, db, api_client, url):
+ user = FidesUser.create(
+ db=db,
+ data={
+ "username": "valid_user",
+ },
+ )
+ FidesUserPermissions.create(
+ db=db,
+ data={"user_id": user.id, "roles": [VIEWER]},
+ )
+ FidesUserInvite.create(
+ db=db, data={"username": "valid_user", "invite_code": "valid_code"}
+ )
+
+ response = api_client.post(
+ url,
+ params={"username": "valid_user", "invite_code": "invalid_code"},
+ json={"username": "valid_user", "new_password": "pass"},
+ )
+ assert response.status_code == HTTP_400_BAD_REQUEST
+ assert response.json()["detail"] == "Invite code is invalid."
+
+ @mock.patch("fides.api.api.v1.endpoints.user_endpoints.FidesUserInvite.get_by")
+ def test_accept_invite_expired_code(self, mock_get_by, api_client: TestClient, url):
+ # the expiration is based on the updated_at timestamp so we need to mock an expired FidesUserInvite to test this scenario
+ mock_instance = mock.Mock(
+ spec=FidesUserInvite,
+ invite_code_valid=mock.Mock(return_value=True),
+ is_expired=mock.Mock(return_value=True),
+ )
+ mock_get_by.return_value = mock_instance
+
+ response = api_client.post(
+ url,
+ params={"username": "valid_user", "invite_code": "expired_code"},
+ json={"username": "valid_user", "new_password": "pass"},
+ )
+ assert response.status_code == HTTP_400_BAD_REQUEST
+ assert response.json()["detail"] == "Invite code has expired."
+
+ def test_accept_invite_nonexistent_user(self, api_client, url):
+ response = api_client.post(
+ url,
+ params={"username": "nonexistent_user", "invite_code": "some_code"},
+ json={
+ "username": "nonexistent_user",
+ "new_password": "pass",
+ },
+ )
+ assert response.status_code == HTTP_404_NOT_FOUND
+ assert response.json()["detail"] == "User not found."
diff --git a/tests/ops/models/test_fides_user_invite.py b/tests/ops/models/test_fides_user_invite.py
new file mode 100644
index 0000000000..07cc41f5b2
--- /dev/null
+++ b/tests/ops/models/test_fides_user_invite.py
@@ -0,0 +1,91 @@
+from datetime import datetime, timedelta, timezone
+from typing import Generator
+
+import pytest
+from sqlalchemy.orm import Session
+
+from fides.api.models.fides_user import FidesUser
+from fides.api.models.fides_user_invite import INVITE_CODE_TTL_HOURS, FidesUserInvite
+from fides.api.models.fides_user_permissions import FidesUserPermissions
+from fides.api.oauth.roles import VIEWER
+
+
+class TestFidesUserInvite:
+
+ @pytest.fixture(scope="function")
+ def fides_user(self, db: Session) -> Generator:
+ user = FidesUser.create(
+ db=db,
+ data={
+ "username": "test",
+ },
+ )
+ FidesUserPermissions.create(
+ db=db,
+ data={"user_id": user.id, "roles": [VIEWER]},
+ )
+ yield user
+ user.delete(db)
+
+ @pytest.mark.usefixtures("fides_user")
+ def test_create(self, db: Session):
+ username = "test"
+ invite_code = "test_invite"
+ user_invite = FidesUserInvite.create(
+ db=db, data={"username": "test", "invite_code": invite_code}
+ )
+
+ assert user_invite.username == username
+ assert user_invite.hashed_invite_code is not None
+ assert user_invite.salt is not None
+ assert user_invite.created_at is not None
+ assert user_invite.updated_at is not None
+
+ @pytest.mark.usefixtures("fides_user")
+ def test_invite_code_valid(self, db: Session):
+ username = "test"
+ invite_code = "test_invite"
+ user_invite = FidesUserInvite.create(
+ db=db, data={"username": username, "invite_code": invite_code}
+ )
+
+ assert user_invite.invite_code_valid(invite_code) is True
+ assert user_invite.invite_code_valid("wrong_code") is False
+
+ @pytest.mark.usefixtures("fides_user")
+ def test_is_expired(self, db: Session):
+ username = "test"
+ invite_code = "test_invite"
+ user_invite = FidesUserInvite.create(
+ db=db, data={"username": username, "invite_code": invite_code}
+ )
+ assert user_invite.is_expired() is False
+
+ # Manually set 'updated_at' to simulate an expired invite
+ user_invite.updated_at = datetime.now(timezone.utc) - timedelta(
+ hours=INVITE_CODE_TTL_HOURS + 1
+ )
+ assert user_invite.is_expired() is True
+
+ @pytest.mark.usefixtures("fides_user")
+ def test_renew_invite(self, db: Session):
+ username = "test"
+ initial_invite_code = "initial_invite"
+ new_invite_code = "new_invite"
+
+ # Create initial invite
+ user_invite = FidesUserInvite.create(
+ db=db, data={"username": username, "invite_code": initial_invite_code}
+ )
+ original_hashed_code = user_invite.hashed_invite_code
+ original_salt = user_invite.salt
+ original_updated_at = user_invite.updated_at
+
+ # Refresh invite with new code
+ user_invite.renew_invite(db, new_invite_code)
+ db.refresh(user_invite)
+
+ assert user_invite.hashed_invite_code != original_hashed_code
+ assert user_invite.salt != original_salt
+ assert user_invite.updated_at > original_updated_at
+ assert user_invite.invite_code_valid(new_invite_code)
diff --git a/tests/ops/service/messaging/message_dispatch_service_test.py b/tests/ops/service/messaging/message_dispatch_service_test.py
index 92d8f68ec3..81ad557a8b 100644
--- a/tests/ops/service/messaging/message_dispatch_service_test.py
+++ b/tests/ops/service/messaging/message_dispatch_service_test.py
@@ -7,6 +7,7 @@
from sqlalchemy.orm import Session
from fides.api.common_exceptions import MessageDispatchException
+from fides.api.models.application_config import ApplicationConfig
from fides.api.models.messaging import MessagingConfig
from fides.api.models.privacy_notice import (
ConsentMechanism,
@@ -25,6 +26,7 @@
MessagingServiceType,
RequestReviewDenyBodyParams,
SubjectIdentityVerificationBodyParams,
+ UserInviteBodyParams,
)
from fides.api.schemas.privacy_notice import PrivacyNoticeHistorySchema
from fides.api.schemas.privacy_preference import MinimalPrivacyPreferenceHistorySchema
@@ -39,6 +41,7 @@
_twilio_sms_dispatcher,
dispatch_message,
)
+from fides.config import CONFIG
@pytest.fixture
@@ -845,6 +848,46 @@ def test_email_dispatch_consent_request_email_fulfillment_for_sovrn_new_workflow
"sovrn_test@example.com",
)
+ @pytest.fixture
+ def mock_config_admin_ui_url(self, db):
+ original_value = CONFIG.admin_ui.url
+ CONFIG.admin_ui.url = "http://localhost:3000"
+ ApplicationConfig.update_config_set(db, CONFIG)
+ yield
+ CONFIG.admin_ui.url = original_value
+ ApplicationConfig.update_config_set(db, CONFIG)
+
+ @pytest.mark.usefixtures("mock_config_admin_ui_url")
+ @mock.patch(
+ "fides.api.service.messaging.message_dispatch_service._mailgun_dispatcher"
+ )
+ def test_email_dispatch_user_invite_email(
+ self,
+ mock_mailgun_dispatcher: Mock,
+ db: Session,
+ messaging_config,
+ ) -> None:
+ dispatch_message(
+ db=db,
+ action_type=MessagingActionType.USER_INVITE,
+ to_identity=Identity(**{"email": "test@example.com"}),
+ service_type=MessagingServiceType.mailgun.value,
+ message_body_params=UserInviteBodyParams(
+ username="test", invite_code="123"
+ ),
+ )
+
+ body = '\n\n \n \n Welcome to Fides\n \n \n \n You\'ve been invited to join Fides, click here to accept the invite and setup your account. \n \n \n'
+
+ mock_mailgun_dispatcher.assert_called_with(
+ messaging_config,
+ EmailForActionType(
+ subject="Welcome to Fides",
+ body=body,
+ ),
+ "test@example.com",
+ )
+
class TestTwilioEmailDispatcher:
def test_dispatch_no_to(self, messaging_config_twilio_email):
|