Merge pull request #14744 from Budibase/logged-out-search-fix

Still fetch flags when the user is not logged in.
This commit is contained in:
Sam Rose 2024-10-09 15:34:59 +01:00 committed by GitHub
commit 3814eeb475
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 113 additions and 80 deletions

View File

@ -253,6 +253,11 @@ export function getAppId(): string | undefined {
} }
} }
export function getIP(): string | undefined {
const context = Context.get()
return context?.ip
}
export const getProdAppId = () => { export const getProdAppId = () => {
const appId = getAppId() const appId = getAppId()
if (!appId) { if (!appId) {
@ -281,6 +286,10 @@ export function doInScimContext(task: any) {
return newContext(updates, task) return newContext(updates, task)
} }
export function doInIPContext(ip: string, task: any) {
return newContext({ ip }, task)
}
export async function ensureSnippetContext(enabled = !env.isTest()) { export async function ensureSnippetContext(enabled = !env.isTest()) {
const ctx = getCurrentContext() const ctx = getCurrentContext()

View File

@ -9,6 +9,7 @@ export type ContextMap = {
identity?: IdentityContext identity?: IdentityContext
environmentVariables?: Record<string, string> environmentVariables?: Record<string, string>
isScim?: boolean isScim?: boolean
ip?: string
automationId?: string automationId?: string
isMigrating?: boolean isMigrating?: boolean
vm?: VM vm?: VM

View File

@ -1,7 +1,8 @@
import env from "../environment" import env from "../environment"
import * as crypto from "crypto"
import * as context from "../context" import * as context from "../context"
import { PostHog, PostHogOptions } from "posthog-node" import { PostHog, PostHogOptions } from "posthog-node"
import { FeatureFlag, IdentityType, UserCtx } from "@budibase/types" import { FeatureFlag } from "@budibase/types"
import tracer from "dd-trace" import tracer from "dd-trace"
import { Duration } from "../utils" import { Duration } from "../utils"
@ -141,23 +142,17 @@ export class FlagSet<V extends Flag<any>, T extends { [key: string]: V }> {
return this.flagSchema[name as keyof T] !== undefined return this.flagSchema[name as keyof T] !== undefined
} }
async get<K extends keyof T>( async get<K extends keyof T>(key: K): Promise<FlagValues<T>[K]> {
key: K, const flags = await this.fetch()
ctx?: UserCtx
): Promise<FlagValues<T>[K]> {
const flags = await this.fetch(ctx)
return flags[key] return flags[key]
} }
async isEnabled<K extends KeysOfType<T, boolean>>( async isEnabled<K extends KeysOfType<T, boolean>>(key: K): Promise<boolean> {
key: K, const flags = await this.fetch()
ctx?: UserCtx
): Promise<boolean> {
const flags = await this.fetch(ctx)
return flags[key] return flags[key]
} }
async fetch(ctx?: UserCtx): Promise<FlagValues<T>> { async fetch(): Promise<FlagValues<T>> {
return await tracer.trace("features.fetch", async span => { return await tracer.trace("features.fetch", async span => {
const cachedFlags = context.getFeatureFlags<FlagValues<T>>(this.setId) const cachedFlags = context.getFeatureFlags<FlagValues<T>>(this.setId)
if (cachedFlags) { if (cachedFlags) {
@ -198,50 +193,33 @@ export class FlagSet<V extends Flag<any>, T extends { [key: string]: V }> {
tags[`flags.${key}.source`] = "environment" tags[`flags.${key}.source`] = "environment"
} }
const license = ctx?.user?.license const identity = context.getIdentity()
if (license) {
tags[`readFromLicense`] = true
for (const feature of license.features) { let userId = identity?._id
if (!this.isFlagName(feature)) { if (!userId) {
continue const ip = context.getIP()
} if (ip) {
userId = crypto.createHash("sha512").update(ip).digest("hex")
if (
flagValues[feature] === true ||
specificallySetFalse.has(feature)
) {
// If the flag is already set to through environment variables, we
// don't want to override it back to false here.
continue
}
// @ts-expect-error - TS does not like you writing into a generic type,
// but we know that it's okay in this case because it's just an object.
flagValues[feature] = true
tags[`flags.${feature}.source`] = "license"
} }
} }
const identity = context.getIdentity() let tenantId = identity?.tenantId
tags[`identity.type`] = identity?.type if (!tenantId) {
tags[`identity.tenantId`] = identity?.tenantId tenantId = currentTenantId
tags[`identity._id`] = identity?._id }
if (posthog && identity?.type === IdentityType.USER) { tags[`identity.type`] = identity?.type
tags[`identity._id`] = identity?._id
tags[`tenantId`] = tenantId
tags[`userId`] = userId
if (posthog && userId) {
tags[`readFromPostHog`] = true tags[`readFromPostHog`] = true
const personProperties: Record<string, string> = {} const personProperties: Record<string, string> = { tenantId }
if (identity.tenantId) { const posthogFlags = await posthog.getAllFlagsAndPayloads(userId, {
personProperties.tenantId = identity.tenantId personProperties,
} })
const posthogFlags = await posthog.getAllFlagsAndPayloads(
identity._id,
{
personProperties,
}
)
for (const [name, value] of Object.entries(posthogFlags.featureFlags)) { for (const [name, value] of Object.entries(posthogFlags.featureFlags)) {
if (!this.isFlagName(name)) { if (!this.isFlagName(name)) {

View File

@ -1,9 +1,10 @@
import { IdentityContext, IdentityType, UserCtx } from "@budibase/types" import { IdentityContext, IdentityType } from "@budibase/types"
import { Flag, FlagSet, FlagValues, init, shutdown } from "../" import { Flag, FlagSet, FlagValues, init, shutdown } from "../"
import * as context from "../../context" import * as context from "../../context"
import environment, { withEnv } from "../../environment" import environment, { withEnv } from "../../environment"
import nodeFetch from "node-fetch" import nodeFetch from "node-fetch"
import nock from "nock" import nock from "nock"
import * as crypto from "crypto"
const schema = { const schema = {
TEST_BOOLEAN: Flag.boolean(false), TEST_BOOLEAN: Flag.boolean(false),
@ -17,7 +18,6 @@ interface TestCase {
identity?: Partial<IdentityContext> identity?: Partial<IdentityContext>
environmentFlags?: string environmentFlags?: string
posthogFlags?: PostHogFlags posthogFlags?: PostHogFlags
licenseFlags?: Array<string>
expected?: Partial<FlagValues<typeof schema>> expected?: Partial<FlagValues<typeof schema>>
errorMessage?: string | RegExp errorMessage?: string | RegExp
} }
@ -27,10 +27,14 @@ interface PostHogFlags {
featureFlagPayloads?: Record<string, string> featureFlagPayloads?: Record<string, string>
} }
function mockPosthogFlags(flags: PostHogFlags) { function mockPosthogFlags(
flags: PostHogFlags,
opts?: { token?: string; distinct_id?: string }
) {
const { token = "test", distinct_id = "us_1234" } = opts || {}
nock("https://us.i.posthog.com") nock("https://us.i.posthog.com")
.post("/decide/?v=3", body => { .post("/decide/?v=3", body => {
return body.token === "test" && body.distinct_id === "us_1234" return body.token === token && body.distinct_id === distinct_id
}) })
.reply(200, flags) .reply(200, flags)
.persist() .persist()
@ -112,17 +116,6 @@ describe("feature flags", () => {
}, },
expected: { TEST_BOOLEAN: true }, expected: { TEST_BOOLEAN: true },
}, },
{
it: "should be able to set boolean flags through the license",
licenseFlags: ["TEST_BOOLEAN"],
expected: { TEST_BOOLEAN: true },
},
{
it: "should not be able to override a negative environment flag from license",
environmentFlags: "default:!TEST_BOOLEAN",
licenseFlags: ["TEST_BOOLEAN"],
expected: { TEST_BOOLEAN: false },
},
{ {
it: "should not error on unrecognised PostHog flag", it: "should not error on unrecognised PostHog flag",
posthogFlags: { posthogFlags: {
@ -130,18 +123,12 @@ describe("feature flags", () => {
}, },
expected: flags.defaults(), expected: flags.defaults(),
}, },
{
it: "should not error on unrecognised license flag",
licenseFlags: ["UNDEFINED"],
expected: flags.defaults(),
},
])( ])(
"$it", "$it",
async ({ async ({
identity, identity,
environmentFlags, environmentFlags,
posthogFlags, posthogFlags,
licenseFlags,
expected, expected,
errorMessage, errorMessage,
}) => { }) => {
@ -157,8 +144,6 @@ describe("feature flags", () => {
env.POSTHOG_API_HOST = "https://us.i.posthog.com" env.POSTHOG_API_HOST = "https://us.i.posthog.com"
} }
const ctx = { user: { license: { features: licenseFlags || [] } } }
await withEnv(env, async () => { await withEnv(env, async () => {
// We need to pass in node-fetch here otherwise nock won't get used // We need to pass in node-fetch here otherwise nock won't get used
// because posthog-node uses axios under the hood. // because posthog-node uses axios under the hood.
@ -180,18 +165,13 @@ describe("feature flags", () => {
await context.doInIdentityContext(fullIdentity, async () => { await context.doInIdentityContext(fullIdentity, async () => {
if (errorMessage) { if (errorMessage) {
await expect(flags.fetch(ctx as UserCtx)).rejects.toThrow( await expect(flags.fetch()).rejects.toThrow(errorMessage)
errorMessage
)
} else if (expected) { } else if (expected) {
const values = await flags.fetch(ctx as UserCtx) const values = await flags.fetch()
expect(values).toMatchObject(expected) expect(values).toMatchObject(expected)
for (const [key, expectedValue] of Object.entries(expected)) { for (const [key, expectedValue] of Object.entries(expected)) {
const value = await flags.get( const value = await flags.get(key as keyof typeof schema)
key as keyof typeof schema,
ctx as UserCtx
)
expect(value).toBe(expectedValue) expect(value).toBe(expectedValue)
} }
} else { } else {
@ -214,6 +194,14 @@ describe("feature flags", () => {
lastName: "User", lastName: "User",
} }
// We need to pass in node-fetch here otherwise nock won't get used
// because posthog-node uses axios under the hood.
init({
fetch: (url, opts) => {
return nodeFetch(url, opts)
},
})
nock("https://us.i.posthog.com") nock("https://us.i.posthog.com")
.post("/decide/?v=3", body => { .post("/decide/?v=3", body => {
return body.token === "test" && body.distinct_id === "us_1234" return body.token === "test" && body.distinct_id === "us_1234"
@ -230,4 +218,44 @@ describe("feature flags", () => {
} }
) )
}) })
it("should still get flags when user is logged out", async () => {
const env: Partial<typeof environment> = {
SELF_HOSTED: false,
POSTHOG_FEATURE_FLAGS_ENABLED: "true",
POSTHOG_API_HOST: "https://us.i.posthog.com",
POSTHOG_TOKEN: "test",
}
const ip = "127.0.0.1"
const hashedIp = crypto.createHash("sha512").update(ip).digest("hex")
await withEnv(env, async () => {
mockPosthogFlags(
{
featureFlags: { TEST_BOOLEAN: true },
},
{
distinct_id: hashedIp,
}
)
// We need to pass in node-fetch here otherwise nock won't get used
// because posthog-node uses axios under the hood.
init({
fetch: (url, opts) => {
return nodeFetch(url, opts)
},
})
await context.doInIPContext(ip, async () => {
await context.doInTenant("default", async () => {
const result = await flags.fetch()
expect(result.TEST_BOOLEAN).toBe(true)
})
})
shutdown()
})
})
}) })

View File

@ -20,3 +20,4 @@ export { default as correlation } from "../logging/correlation/middleware"
export { default as errorHandling } from "./errorHandling" export { default as errorHandling } from "./errorHandling"
export { default as querystringToBody } from "./querystringToBody" export { default as querystringToBody } from "./querystringToBody"
export * as joiValidator from "./joi-validator" export * as joiValidator from "./joi-validator"
export { default as ip } from "./ip"

View File

@ -0,0 +1,12 @@
import { Ctx } from "@budibase/types"
import { doInIPContext } from "../context"
export default async (ctx: Ctx, next: any) => {
if (ctx.ip) {
return await doInIPContext(ctx.ip, () => {
return next()
})
} else {
return next()
}
}

View File

@ -12,6 +12,7 @@ import { userAgent } from "koa-useragent"
export default function createKoaApp() { export default function createKoaApp() {
const app = new Koa() const app = new Koa()
app.proxy = true
let mbNumber = parseInt(env.HTTP_MB_LIMIT || "10") let mbNumber = parseInt(env.HTTP_MB_LIMIT || "10")
if (!mbNumber || isNaN(mbNumber)) { if (!mbNumber || isNaN(mbNumber)) {
@ -35,6 +36,7 @@ export default function createKoaApp() {
app.use(middleware.correlation) app.use(middleware.correlation)
app.use(middleware.pino) app.use(middleware.pino)
app.use(middleware.ip)
app.use(userAgent) app.use(userAgent)
const server = http.createServer(app.callback()) const server = http.createServer(app.callback())

View File

@ -46,6 +46,7 @@ bootstrap()
const app: Application = new Application() const app: Application = new Application()
app.keys = ["secret", "key"] app.keys = ["secret", "key"]
app.proxy = true
// set up top level koa middleware // set up top level koa middleware
app.use(handleScimBody) app.use(handleScimBody)
@ -54,6 +55,7 @@ app.use(koaBody({ multipart: true }))
app.use(koaSession(app)) app.use(koaSession(app))
app.use(middleware.correlation) app.use(middleware.correlation)
app.use(middleware.pino) app.use(middleware.pino)
app.use(middleware.ip)
app.use(userAgent) app.use(userAgent)
// authentication // authentication