Patch service account UI in audit logs, add lastUsed for API keys and service accounts/tokens

This commit is contained in:
Tuan Dang
2023-04-06 23:52:06 +03:00
parent d54753289a
commit a7880db871
10 changed files with 145 additions and 138 deletions
@@ -50,6 +50,7 @@ export const createAPIKeyData = async (req: Request, res: Response) => {
apiKeyData = await new APIKeyData({ apiKeyData = await new APIKeyData({
name, name,
lastUsed: new Date(),
expiresAt, expiresAt,
user: req.user._id, user: req.user._id,
secretHash secretHash
@@ -80,6 +80,7 @@ export const createServiceAccount = async (req: Request, res: Response) => {
organization: new Types.ObjectId(organizationId), organization: new Types.ObjectId(organizationId),
user: req.user, user: req.user,
publicKey, publicKey,
lastUsed: new Date(),
expiresAt, expiresAt,
secretHash secretHash
}).save(); }).save();
@@ -94,6 +94,7 @@ export const createServiceTokenData = async (req: Request, res: Response) => {
environment, environment,
user, user,
serviceAccount, serviceAccount,
lastUsed: new Date(),
expiresAt, expiresAt,
secretHash, secretHash,
encryptedKey, encryptedKey,
@@ -102,7 +103,6 @@ export const createServiceTokenData = async (req: Request, res: Response) => {
permissions permissions
}).save(); }).save();
// return service token data without sensitive data // return service token data without sensitive data
serviceTokenData = await ServiceTokenData.findById(serviceTokenData._id); serviceTokenData = await ServiceTokenData.findById(serviceTokenData._id);
@@ -123,25 +123,11 @@ export const createServiceTokenData = async (req: Request, res: Response) => {
* @returns * @returns
*/ */
export const deleteServiceTokenData = async (req: Request, res: Response) => { export const deleteServiceTokenData = async (req: Request, res: Response) => {
let serviceTokenData; const { serviceTokenDataId } = req.params;
try {
const { serviceTokenDataId } = req.params;
serviceTokenData = await ServiceTokenData.findByIdAndDelete(serviceTokenDataId); const serviceTokenData = await ServiceTokenData.findByIdAndDelete(serviceTokenDataId);
} catch (err) {
Sentry.setUser({ email: req.user.email });
Sentry.captureException(err);
return res.status(400).send({
message: 'Failed to delete service token data'
});
}
return res.status(200).send({ return res.status(200).send({
serviceTokenData serviceTokenData
}); });
}
function UnauthorizedRequestError(arg0: { message: string; }) {
throw new Error('Function not implemented.');
} }
@@ -418,7 +418,7 @@ export const getWorkspaceLogs = async (req: Request, res: Response) => {
.skip(offset) .skip(offset)
.limit(limit) .limit(limit)
.populate('actions') .populate('actions')
.populate('user'); .populate('user serviceAccount serviceTokenData');
} catch (err) { } catch (err) {
Sentry.setUser({ email: req.user.email }); Sentry.setUser({ email: req.user.email });
+97 -115
View File
@@ -1,4 +1,5 @@
import * as Sentry from '@sentry/node'; import * as Sentry from '@sentry/node';
import { Types } from 'mongoose';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import bcrypt from 'bcrypt'; import bcrypt from 'bcrypt';
import { import {
@@ -102,25 +103,17 @@ const getAuthUserPayload = async ({
}: { }: {
authTokenValue: string; authTokenValue: string;
}) => { }) => {
let user; const decodedToken = <jwt.UserIDJwtPayload>(
try { jwt.verify(authTokenValue, getJwtAuthSecret())
const decodedToken = <jwt.UserIDJwtPayload>( );
jwt.verify(authTokenValue, getJwtAuthSecret())
);
user = await User.findOne({ const user = await User.findOne({
_id: decodedToken.userId _id: decodedToken.userId
}).select('+publicKey'); }).select('+publicKey');
if (!user) throw AccountNotFoundError({ message: 'Failed to find User' }); if (!user) throw AccountNotFoundError({ message: 'Failed to find User' });
if (!user?.publicKey) throw UnauthorizedRequestError({ message: 'Failed to authenticate User with partially set up account' }); if (!user?.publicKey) throw UnauthorizedRequestError({ message: 'Failed to authenticate User with partially set up account' });
} catch (err) {
throw UnauthorizedRequestError({
message: 'Failed to authenticate JWT token'
});
}
return user; return user;
} }
@@ -136,41 +129,38 @@ const getAuthSTDPayload = async ({
}: { }: {
authTokenValue: string; authTokenValue: string;
}) => { }) => {
let serviceTokenData; const [_, TOKEN_IDENTIFIER, TOKEN_SECRET] = <[string, string, string]>authTokenValue.split('.', 3);
try {
const [_, TOKEN_IDENTIFIER, TOKEN_SECRET] = <[string, string, string]>authTokenValue.split('.', 3);
// TODO: optimize double query let serviceTokenData = await ServiceTokenData
serviceTokenData = await ServiceTokenData .findById(TOKEN_IDENTIFIER, '+secretHash +expiresAt');
.findById(TOKEN_IDENTIFIER, '+secretHash +expiresAt');
if (!serviceTokenData) { if (!serviceTokenData) {
throw ServiceTokenDataNotFoundError({ message: 'Failed to find service token data' }); throw ServiceTokenDataNotFoundError({ message: 'Failed to find service token data' });
} else if (serviceTokenData?.expiresAt && new Date(serviceTokenData.expiresAt) < new Date()) { } else if (serviceTokenData?.expiresAt && new Date(serviceTokenData.expiresAt) < new Date()) {
// case: service token expired // case: service token expired
await ServiceTokenData.findByIdAndDelete(serviceTokenData._id); await ServiceTokenData.findByIdAndDelete(serviceTokenData._id);
throw UnauthorizedRequestError({
message: 'Failed to authenticate expired service token'
});
}
const isMatch = await bcrypt.compare(TOKEN_SECRET, serviceTokenData.secretHash);
if (!isMatch) throw UnauthorizedRequestError({
message: 'Failed to authenticate service token'
});
serviceTokenData = await ServiceTokenData
.findById(TOKEN_IDENTIFIER)
.select('+encryptedKey +iv +tag');
if (!serviceTokenData) throw ServiceTokenDataNotFoundError({ message: 'Failed to find service token data' });
} catch (err) {
throw UnauthorizedRequestError({ throw UnauthorizedRequestError({
message: 'Failed to authenticate service token' message: 'Failed to authenticate expired service token'
}); });
} }
const isMatch = await bcrypt.compare(TOKEN_SECRET, serviceTokenData.secretHash);
if (!isMatch) throw UnauthorizedRequestError({
message: 'Failed to authenticate service token'
});
serviceTokenData = await ServiceTokenData
.findOneAndUpdate({
_id: new Types.ObjectId(TOKEN_IDENTIFIER)
}, {
lastUsed: new Date()
}, {
new: true
})
.select('+encryptedKey +iv +tag');
if (!serviceTokenData) throw ServiceTokenDataNotFoundError({ message: 'Failed to find service token data' });
return serviceTokenData; return serviceTokenData;
} }
@@ -204,7 +194,6 @@ const getAuthSAAKPayload = async ({
} }
/** /**
* TODO: deprecate API keys
* Return API key data payload corresponding to API key [authTokenValue] * Return API key data payload corresponding to API key [authTokenValue]
* @param {Object} obj * @param {Object} obj
* @param {String} obj.authTokenValue - API key value * @param {String} obj.authTokenValue - API key value
@@ -215,33 +204,44 @@ const getAuthAPIKeyPayload = async ({
}: { }: {
authTokenValue: string; authTokenValue: string;
}) => { }) => {
let user; const [_, TOKEN_IDENTIFIER, TOKEN_SECRET] = <[string, string, string]>authTokenValue.split('.', 3);
try {
const [_, TOKEN_IDENTIFIER, TOKEN_SECRET] = <[string, string, string]>authTokenValue.split('.', 3);
const apiKeyData = await APIKeyData let apiKeyData = await APIKeyData
.findById(TOKEN_IDENTIFIER, '+secretHash +expiresAt') .findById(TOKEN_IDENTIFIER, '+secretHash +expiresAt')
.populate<{user: IUser}>('user', '+publicKey'); .populate<{user: IUser}>('user', '+publicKey');
if (!apiKeyData) { if (!apiKeyData) {
throw APIKeyDataNotFoundError({ message: 'Failed to find API key data' }); throw APIKeyDataNotFoundError({ message: 'Failed to find API key data' });
} else if (apiKeyData?.expiresAt && new Date(apiKeyData.expiresAt) < new Date()) { } else if (apiKeyData?.expiresAt && new Date(apiKeyData.expiresAt) < new Date()) {
// case: API key expired // case: API key expired
await APIKeyData.findByIdAndDelete(apiKeyData._id); await APIKeyData.findByIdAndDelete(apiKeyData._id);
throw UnauthorizedRequestError({
message: 'Failed to authenticate expired API key'
});
}
const isMatch = await bcrypt.compare(TOKEN_SECRET, apiKeyData.secretHash);
if (!isMatch) throw UnauthorizedRequestError({
message: 'Failed to authenticate API key'
});
user = apiKeyData.user;
} catch (err) {
throw UnauthorizedRequestError({ throw UnauthorizedRequestError({
message: 'Failed to authenticate API key' message: 'Failed to authenticate expired API key'
});
}
const isMatch = await bcrypt.compare(TOKEN_SECRET, apiKeyData.secretHash);
if (!isMatch) throw UnauthorizedRequestError({
message: 'Failed to authenticate API key'
});
apiKeyData = await APIKeyData.findOneAndUpdate({
_id: new Types.ObjectId(TOKEN_IDENTIFIER)
}, {
lastUsed: new Date()
}, {
new: true
});
if (!apiKeyData) {
throw APIKeyDataNotFoundError({ message: 'Failed to find API key data' });
}
const user = await User.findById(apiKeyData.user).select('+publicKey');
if (!user) {
throw AccountNotFoundError({
message: 'Failed to find user'
}); });
} }
@@ -257,30 +257,23 @@ const getAuthAPIKeyPayload = async ({
* @return {String} obj.refreshToken - issued refresh token * @return {String} obj.refreshToken - issued refresh token
*/ */
const issueAuthTokens = async ({ userId }: { userId: string }) => { const issueAuthTokens = async ({ userId }: { userId: string }) => {
let token: string;
let refreshToken: string;
try {
// issue tokens
token = createToken({
payload: {
userId
},
expiresIn: getJwtAuthLifetime(),
secret: getJwtAuthSecret()
});
refreshToken = createToken({ // issue tokens
payload: { const token = createToken({
userId payload: {
}, userId
expiresIn: getJwtRefreshLifetime(), },
secret: getJwtRefreshSecret() expiresIn: getJwtAuthLifetime(),
}); secret: getJwtAuthSecret()
} catch (err) { });
Sentry.setUser(null);
Sentry.captureException(err); const refreshToken = createToken({
throw new Error('Failed to issue tokens'); payload: {
} userId
},
expiresIn: getJwtRefreshLifetime(),
secret: getJwtRefreshSecret()
});
return { return {
token, token,
@@ -294,19 +287,14 @@ const issueAuthTokens = async ({ userId }: { userId: string }) => {
* @param {String} obj.userId - id of user whose tokens are cleared. * @param {String} obj.userId - id of user whose tokens are cleared.
*/ */
const clearTokens = async ({ userId }: { userId: string }): Promise<void> => { const clearTokens = async ({ userId }: { userId: string }): Promise<void> => {
try { // increment refreshVersion on user by 1
// increment refreshVersion on user by 1 User.findOneAndUpdate({
User.findOneAndUpdate({ _id: userId
_id: userId }, {
}, { $inc: {
$inc: { refreshVersion: 1
refreshVersion: 1 }
} });
});
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
}
}; };
/** /**
@@ -326,15 +314,9 @@ const createToken = ({
expiresIn: string | number; expiresIn: string | number;
secret: string; secret: string;
}) => { }) => {
try { return jwt.sign(payload, secret, {
return jwt.sign(payload, secret, { expiresIn
expiresIn });
});
} catch (err) {
Sentry.setUser(null);
Sentry.captureException(err);
throw new Error('Failed to create a token');
}
}; };
export { export {
+4
View File
@@ -3,6 +3,7 @@ import { Schema, model, Types } from 'mongoose';
export interface IAPIKeyData { export interface IAPIKeyData {
name: string; name: string;
user: Types.ObjectId; user: Types.ObjectId;
lastUsed: Date;
expiresAt: Date; expiresAt: Date;
secretHash: string; secretHash: string;
} }
@@ -18,6 +19,9 @@ const apiKeyDataSchema = new Schema<IAPIKeyData>(
ref: 'User', ref: 'User',
required: true required: true
}, },
lastUsed: {
type: Date
},
expiresAt: { expiresAt: {
type: Date type: Date
}, },
+5 -1
View File
@@ -6,6 +6,7 @@ export interface IServiceAccount extends Document {
organization: Types.ObjectId; organization: Types.ObjectId;
user: Types.ObjectId; user: Types.ObjectId;
publicKey: string; publicKey: string;
lastUsed: Date;
expiresAt: Date; expiresAt: Date;
secretHash: string; secretHash: string;
} }
@@ -30,6 +31,9 @@ const serviceAccountSchema = new Schema<IServiceAccount>(
type: String, type: String,
required: true required: true
}, },
lastUsed: {
type: Date
},
expiresAt: { expiresAt: {
type: Date type: Date
}, },
@@ -44,6 +48,6 @@ const serviceAccountSchema = new Schema<IServiceAccount>(
} }
); );
const ServiceAccount = model<IServiceAccount>('ServiceAcount', serviceAccountSchema); const ServiceAccount = model<IServiceAccount>('ServiceAccount', serviceAccountSchema);
export default ServiceAccount; export default ServiceAccount;
+5 -1
View File
@@ -7,6 +7,7 @@ export interface IServiceTokenData extends Document {
environment: string; environment: string;
user: Types.ObjectId; user: Types.ObjectId;
serviceAccount: Types.ObjectId; serviceAccount: Types.ObjectId;
lastUsed: Date;
expiresAt: Date; expiresAt: Date;
secretHash: string; secretHash: string;
encryptedKey: string; encryptedKey: string;
@@ -26,7 +27,7 @@ const serviceTokenDataSchema = new Schema<IServiceTokenData>(
ref: 'Workspace', ref: 'Workspace',
required: true required: true
}, },
environment: { // TODO: adapt to upcoming environment id environment: {
type: String, type: String,
required: true required: true
}, },
@@ -38,6 +39,9 @@ const serviceTokenDataSchema = new Schema<IServiceTokenData>(
type: Schema.Types.ObjectId, type: Schema.Types.ObjectId,
ref: 'ServiceAccount' ref: 'ServiceAccount'
}, },
lastUsed: {
type: Date
},
expiresAt: { expiresAt: {
type: Date type: Date
}, },
+15 -1
View File
@@ -21,6 +21,12 @@ interface LogData {
createdAt: string; createdAt: string;
ipAddress: string; ipAddress: string;
user: string; user: string;
serviceAccount: {
name: string;
};
serviceTokenData: {
name: string;
};
payload: PayloadProps[]; payload: PayloadProps[];
} }
@@ -41,6 +47,14 @@ const ActivityLogsRow = ({
const [payloadOpened, setPayloadOpened] = useState(false); const [payloadOpened, setPayloadOpened] = useState(false);
const { t } = useTranslation(); const { t } = useTranslation();
const renderUser = () => {
if (row?.user) return `User: ${row.user}`;
if (row?.serviceAccount) return `Service Account: ${row.serviceAccount.name}`;
if (row?.serviceTokenData.name) return `Service Token: ${row.serviceTokenData.name}`;
return '';
}
return ( return (
<> <>
<tr key={guidGenerator()} className="bg-bunker-800 duration-100 w-full text-sm"> <tr key={guidGenerator()} className="bg-bunker-800 duration-100 w-full text-sm">
@@ -64,7 +78,7 @@ const ActivityLogsRow = ({
) )
.join(' and ')} .join(' and ')}
</td> </td>
<td className="pl-6 py-3 border-mineshaft-700 border-t text-gray-300">{row.user}</td> <td className="pl-6 py-3 border-mineshaft-700 border-t text-gray-300">{renderUser()}</td>
<td className="pl-6 py-3 border-mineshaft-700 border-t text-gray-300">{row.channel}</td> <td className="pl-6 py-3 border-mineshaft-700 border-t text-gray-300">{row.channel}</td>
<td className="pl-6 py-3 border-mineshaft-700 border-t text-gray-300"> <td className="pl-6 py-3 border-mineshaft-700 border-t text-gray-300">
{timeSince(new Date(row.createdAt))} {timeSince(new Date(row.createdAt))}
+13 -2
View File
@@ -20,6 +20,12 @@ interface LogData {
user: { user: {
email: string; email: string;
}; };
serviceAccount?: {
string: string;
},
serviceTokenData?: {
name: string;
}
actions: { actions: {
_id: string; _id: string;
name: string; name: string;
@@ -69,13 +75,16 @@ export default function Activity() {
userId: '', userId: '',
actionNames: eventChosen actionNames: eventChosen
}); });
setLogsData( setLogsData(
tempLogsData.map((log: LogData) => ({ tempLogsData.map((log: LogData) => ({
_id: log._id, _id: log._id,
channel: log.channel, channel: log.channel,
createdAt: log.createdAt, createdAt: log.createdAt,
ipAddress: log.ipAddress, ipAddress: log.ipAddress,
user: log.user.email, user: log?.user?.email,
serviceAccount: log?.serviceAccount,
serviceTokenData: log?.serviceTokenData,
payload: log.actions.map((action) => ({ payload: log.actions.map((action) => ({
_id: action._id, _id: action._id,
name: action.name, name: action.name,
@@ -106,7 +115,9 @@ export default function Activity() {
channel: log.channel, channel: log.channel,
createdAt: log.createdAt, createdAt: log.createdAt,
ipAddress: log.ipAddress, ipAddress: log.ipAddress,
user: log.user.email, user: log?.user?.email,
serviceAccount: log?.serviceAccount,
serviceTokenData: log?.serviceTokenData,
payload: log.actions.map((action) => ({ payload: log.actions.map((action) => ({
_id: action._id, _id: action._id,
name: action.name, name: action.name,