From 1bf90129928694e0b8b9dc71d3f62135ecb3e9c4 Mon Sep 17 00:00:00 2001
From: Hemachandar <132386067+hmacr@users.noreply.github.com>
Date: Thu, 20 Jun 2024 18:48:35 +0530
Subject: [PATCH] feat: Add lastUsedAt to API keys (#7082)
* feat: Add lastUsedAt to API keys
* rename column to lastActiveAt
* switch order
---
app/models/ApiKey.ts | 6 +++++
.../Settings/components/ApiKeyListItem.tsx | 6 +++++
server/middlewares/authentication.ts | 2 ++
...240618201908-add-lastActiveAt-to-apikey.js | 15 +++++++++++++
server/models/ApiKey.test.ts | 22 +++++++++++++++++++
server/models/ApiKey.ts | 17 ++++++++++++++
server/presenters/apiKey.ts | 1 +
server/routes/api/apiKeys/apiKeys.test.ts | 2 ++
shared/i18n/locales/en_US/translation.json | 1 +
9 files changed, 72 insertions(+)
create mode 100644 server/migrations/20240618201908-add-lastActiveAt-to-apikey.js
diff --git a/app/models/ApiKey.ts b/app/models/ApiKey.ts
index dddeddaf6..b62482801 100644
--- a/app/models/ApiKey.ts
+++ b/app/models/ApiKey.ts
@@ -24,6 +24,12 @@ class ApiKey extends Model {
@observable
expiresAt?: string;
+ /**
+ * An optional datetime that the API key was last used at.
+ */
+ @observable
+ lastActiveAt?: string;
+
secret: string;
/**
diff --git a/app/scenes/Settings/components/ApiKeyListItem.tsx b/app/scenes/Settings/components/ApiKeyListItem.tsx
index b60126f31..243d50d35 100644
--- a/app/scenes/Settings/components/ApiKeyListItem.tsx
+++ b/app/scenes/Settings/components/ApiKeyListItem.tsx
@@ -27,6 +27,12 @@ const ApiKeyListItem = ({ apiKey, isCopied, onCopy }: Props) => {
{t(`Created`)} ·{" "}
+ {apiKey.lastActiveAt && (
+
+ {t("Last used")} {" "}
+ ·{" "}
+
+ )}
{apiKey.expiresAt
? dateToExpiry(apiKey.expiresAt, t, userLocale)
diff --git a/server/middlewares/authentication.ts b/server/middlewares/authentication.ts
index 65fead031..bd0557865 100644
--- a/server/middlewares/authentication.ts
+++ b/server/middlewares/authentication.ts
@@ -100,6 +100,8 @@ export default function auth(options: AuthenticationOptions = {}) {
if (!user) {
throw AuthenticationError("Invalid API key");
}
+
+ await apiKey.updateActiveAt();
} else {
type = AuthenticationType.APP;
user = await getUserForJWT(String(token));
diff --git a/server/migrations/20240618201908-add-lastActiveAt-to-apikey.js b/server/migrations/20240618201908-add-lastActiveAt-to-apikey.js
new file mode 100644
index 000000000..5ab0896d4
--- /dev/null
+++ b/server/migrations/20240618201908-add-lastActiveAt-to-apikey.js
@@ -0,0 +1,15 @@
+"use strict";
+
+/** @type {import('sequelize-cli').Migration} */
+module.exports = {
+ async up(queryInterface, Sequelize) {
+ await queryInterface.addColumn("apiKeys", "lastActiveAt", {
+ type: Sequelize.DATE,
+ allowNull: true,
+ });
+ },
+
+ async down(queryInterface, Sequelize) {
+ await queryInterface.removeColumn("apiKeys", "lastActiveAt");
+ },
+};
diff --git a/server/models/ApiKey.test.ts b/server/models/ApiKey.test.ts
index cd033ee77..29f26f972 100644
--- a/server/models/ApiKey.test.ts
+++ b/server/models/ApiKey.test.ts
@@ -17,4 +17,26 @@ describe("#ApiKey", () => {
expect(ApiKey.match("1234567890")).toBe(false);
});
});
+
+ describe("lastActiveAt", () => {
+ test("should update lastActiveAt", async () => {
+ const apiKey = await buildApiKey({
+ name: "Dev",
+ });
+ await apiKey.updateActiveAt();
+ expect(apiKey.lastActiveAt).toBeTruthy();
+ });
+
+ test("should not update lastActiveAt within 5 minutes", async () => {
+ const apiKey = await buildApiKey({
+ name: "Dev",
+ });
+ await apiKey.updateActiveAt();
+ expect(apiKey.lastActiveAt).toBeTruthy();
+
+ const lastActiveAt = apiKey.lastActiveAt;
+ await apiKey.updateActiveAt();
+ expect(apiKey.lastActiveAt).toEqual(lastActiveAt);
+ });
+ });
});
diff --git a/server/models/ApiKey.ts b/server/models/ApiKey.ts
index d780a86cc..9e9c9ebde 100644
--- a/server/models/ApiKey.ts
+++ b/server/models/ApiKey.ts
@@ -1,3 +1,4 @@
+import { subMinutes } from "date-fns";
import randomstring from "randomstring";
import { InferAttributes, InferCreationAttributes } from "sequelize";
import {
@@ -39,6 +40,10 @@ class ApiKey extends ParanoidModel<
@Column
expiresAt: Date | null;
+ @IsDate
+ @Column
+ lastActiveAt: Date | null;
+
// hooks
@BeforeValidate
@@ -67,6 +72,18 @@ class ApiKey extends ParanoidModel<
@ForeignKey(() => User)
@Column
userId: string;
+
+ updateActiveAt = async () => {
+ const fiveMinutesAgo = subMinutes(new Date(), 5);
+
+ // ensure this is updated only every few minutes otherwise
+ // we'll be constantly writing to the DB as API requests happen
+ if (!this.lastActiveAt || this.lastActiveAt < fiveMinutesAgo) {
+ this.lastActiveAt = new Date();
+ }
+
+ return this.save();
+ };
}
export default ApiKey;
diff --git a/server/presenters/apiKey.ts b/server/presenters/apiKey.ts
index 498aa2262..ef331cfc2 100644
--- a/server/presenters/apiKey.ts
+++ b/server/presenters/apiKey.ts
@@ -8,5 +8,6 @@ export default function presentApiKey(key: ApiKey) {
createdAt: key.createdAt,
updatedAt: key.updatedAt,
expiresAt: key.expiresAt,
+ lastActiveAt: key.lastActiveAt,
};
}
diff --git a/server/routes/api/apiKeys/apiKeys.test.ts b/server/routes/api/apiKeys/apiKeys.test.ts
index ea15d6d18..d733b8704 100644
--- a/server/routes/api/apiKeys/apiKeys.test.ts
+++ b/server/routes/api/apiKeys/apiKeys.test.ts
@@ -20,6 +20,7 @@ describe("#apiKeys.create", () => {
expect(res.status).toEqual(200);
expect(body.data.name).toEqual("My API Key");
expect(body.data.expiresAt).toEqual(now.toISOString());
+ expect(body.data.lastActiveAt).toBeNull();
});
it("should allow creating an api key without expiry", async () => {
@@ -36,6 +37,7 @@ describe("#apiKeys.create", () => {
expect(res.status).toEqual(200);
expect(body.data.name).toEqual("My API Key");
expect(body.data.expiresAt).toBeNull();
+ expect(body.data.lastActiveAt).toBeNull();
});
it("should require authentication", async () => {
diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json
index c7454d946..4f74121aa 100644
--- a/shared/i18n/locales/en_US/translation.json
+++ b/shared/i18n/locales/en_US/translation.json
@@ -768,6 +768,7 @@
"API key copied to clipboard": "API key copied to clipboard",
"Create personal API keys to authenticate with the API and programatically control\n your workspace's data. API keys have the same permissions as your user account.\n For more details see the developer documentation.": "Create personal API keys to authenticate with the API and programatically control\n your workspace's data. API keys have the same permissions as your user account.\n For more details see the developer documentation.",
"Personal keys": "Personal keys",
+ "Last used": "Last used",
"No expiry": "No expiry",
"Copied": "Copied",
"Revoking": "Revoking",