-
Notifications
You must be signed in to change notification settings - Fork 164
Add anonymous access option to core #385
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
7f288c3
1a2ac28
990782a
0d7054b
5abadc1
0623f13
7d0ed5d
348fd89
697fc67
c49bbae
048650c
b41828e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,40 @@ | ||
--- | ||
title: Access Settings | ||
sidebarTitle: Access settings | ||
--- | ||
|
||
There are various settings to control how users access your Sourcebot deployment. | ||
|
||
# Anonymous access | ||
|
||
<Note>Anonymous access cannot be enabled if you have an enterprise license. If you have any questions about this restriction [reach out to us](https://www.sourcebot.dev/contact).</Note> | ||
|
||
By default, your Sourcebot deployment is gated with a login page. If you'd like users to access the deployment anonymously, you can enable anonymous access. | ||
msukkari marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
This can be enabled by navigating to **Settings -> Access** or by setting the `FORCE_ENABLE_ANONYMOUS_ACCESS` environment variable. | ||
|
||
When accessing Sourcebot anonymously, a user's permissions are limited to that of the [Guest](/docs/configuration/auth/roles-and-permissions) role. | ||
|
||
# Member Approval | ||
|
||
By default, Sourcebot requires new members to be approved by the owner of the deployment. This section explains how approvals work and how | ||
to configure this behavior. | ||
|
||
### Configuration | ||
Member approval can be configured by the owner of the deployment by navigating to **Settings -> Members**: | ||
|
||
 | ||
|
||
### Managing Requests | ||
|
||
If member approval is enabled, new members will be asked to submit a join request after signing up. They will not have access to the Sourcebot deployment | ||
until this request is approved by the owner. | ||
|
||
The owner can see and manage all pending join requests by navigating to **Settings -> Members**. | ||
|
||
## Invite link | ||
|
||
If member approval is required, an owner of the deployment can enable an invite link. When enabled, users | ||
can use this invite link to register and be automatically added to the organization without approval: | ||
|
||
 |
This file was deleted.
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -32,12 +32,13 @@ import { decrementOrgSeatCount, getSubscriptionForOrg } from "./ee/features/bill | |
import { bitbucketSchema } from "@sourcebot/schemas/v3/bitbucket.schema"; | ||
import { genericGitHostSchema } from "@sourcebot/schemas/v3/genericGitHost.schema"; | ||
import { getPlan, hasEntitlement } from "@sourcebot/shared"; | ||
import { getPublicAccessStatus } from "./ee/features/publicAccess/publicAccess"; | ||
import JoinRequestSubmittedEmail from "./emails/joinRequestSubmittedEmail"; | ||
import JoinRequestApprovedEmail from "./emails/joinRequestApprovedEmail"; | ||
import { createLogger } from "@sourcebot/logger"; | ||
import { getAuditService } from "@/ee/features/audit/factory"; | ||
import { addUserToOrganization, orgHasAvailability } from "@/lib/authUtils"; | ||
import { getOrgMetadata } from "@/lib/utils"; | ||
import { getOrgFromDomain } from "./data/org"; | ||
|
||
const ajv = new Ajv({ | ||
validateFormats: false, | ||
|
@@ -62,13 +63,13 @@ export const sew = async <T>(fn: () => Promise<T>): Promise<T | ServiceError> => | |
} | ||
} | ||
|
||
export const withAuth = async <T>(fn: (userId: string, apiKeyHash: string | undefined) => Promise<T>, allowSingleTenantUnauthedAccess: boolean = false, apiKey: ApiKeyPayload | undefined = undefined) => { | ||
export const withAuth = async <T>(fn: (userId: string, apiKeyHash: string | undefined) => Promise<T>, allowAnonymousAccess: boolean = false, apiKey: ApiKeyPayload | undefined = undefined) => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not asking for us to do this in this PR, but I feel like this allowAnonymous flag is a bit of a code-smell. I'm pretty sure that in all scenarios where this is set to There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thinking about it more, we could probably eliminate the need for the concept of our sentinel There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah it definitely isn't ideal, but the reason this is needed rn is how we're chaining There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Right. We should probably just combine |
||
const session = await auth(); | ||
|
||
if (!session) { | ||
// First we check if public access is enabled and supported. If not, then we check if an api key was provided. If not, | ||
// then this is an invalid unauthed request and we return a 401. | ||
const publicAccessEnabled = await getPublicAccessStatus(SINGLE_TENANT_ORG_DOMAIN); | ||
const anonymousAccessEnabled = await getAnonymousAccessStatus(SINGLE_TENANT_ORG_DOMAIN); | ||
if (apiKey) { | ||
const apiKeyOrError = await verifyApiKey(apiKey); | ||
if (isServiceError(apiKeyOrError)) { | ||
|
@@ -98,18 +99,17 @@ export const withAuth = async <T>(fn: (userId: string, apiKeyHash: string | unde | |
|
||
return fn(user.id, apiKeyOrError.apiKey.hash); | ||
} else if ( | ||
env.SOURCEBOT_TENANCY_MODE === 'single' && | ||
allowSingleTenantUnauthedAccess && | ||
!isServiceError(publicAccessEnabled) && | ||
publicAccessEnabled | ||
allowAnonymousAccess && | ||
!isServiceError(anonymousAccessEnabled) && | ||
anonymousAccessEnabled | ||
) { | ||
if (!hasEntitlement("public-access")) { | ||
if (!hasEntitlement("anonymous-access")) { | ||
const plan = getPlan(); | ||
logger.error(`Public access isn't supported in your current plan: ${plan}. If you have a valid enterprise license key, pass it via SOURCEBOT_EE_LICENSE_KEY. For support, contact ${SOURCEBOT_SUPPORT_EMAIL}.`); | ||
logger.error(`Anonymous access isn't supported in your current plan: ${plan}. For support, contact ${SOURCEBOT_SUPPORT_EMAIL}.`); | ||
return notAuthenticated(); | ||
} | ||
|
||
// To support unauthed access a guest user is created in initialize.ts, which we return here | ||
// To support anonymous access a guest user is created in initialize.ts, which we return here | ||
return fn(SOURCEBOT_GUEST_USER_ID, undefined); | ||
} | ||
return notAuthenticated(); | ||
|
@@ -672,7 +672,7 @@ export const getRepos = async (domain: string, filter: { status?: RepoIndexingSt | |
indexedAt: repo.indexedAt ?? undefined, | ||
repoIndexingStatus: repo.repoIndexingStatus, | ||
})); | ||
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true | ||
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true | ||
)); | ||
|
||
export const getRepoInfoByName = async (repoName: string, domain: string) => sew(() => | ||
|
@@ -734,7 +734,7 @@ export const getRepoInfoByName = async (repoName: string, domain: string) => sew | |
indexedAt: repo.indexedAt ?? undefined, | ||
repoIndexingStatus: repo.repoIndexingStatus, | ||
} | ||
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true | ||
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true | ||
)); | ||
|
||
export const createConnection = async (name: string, type: CodeHostType, connectionConfig: string, domain: string): Promise<{ id: number } | ServiceError> => sew(() => | ||
|
@@ -933,7 +933,7 @@ export const getCurrentUserRole = async (domain: string): Promise<OrgRole | Serv | |
withAuth((userId) => | ||
withOrgMembership(userId, domain, async ({ userRole }) => { | ||
return userRole; | ||
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true | ||
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true | ||
)); | ||
|
||
export const createInvites = async (emails: string[], domain: string): Promise<{ success: boolean } | ServiceError> => sew(() => | ||
|
@@ -1863,7 +1863,7 @@ export const getSearchContexts = async (domain: string) => sew(() => | |
name: context.name, | ||
description: context.description ?? undefined, | ||
})); | ||
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowSingleTenantUnauthedAccess = */ true | ||
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true | ||
)); | ||
|
||
export const getRepoImage = async (repoId: number, domain: string): Promise<ArrayBuffer | ServiceError> => sew(async () => { | ||
|
@@ -1934,7 +1934,68 @@ export const getRepoImage = async (repoId: number, domain: string): Promise<Arra | |
return notFound(); | ||
} | ||
}, /* minRequiredRole = */ OrgRole.GUEST); | ||
}, /* allowSingleTenantUnauthedAccess = */ true); | ||
}, /* allowAnonymousAccess = */ true); | ||
}); | ||
|
||
export const getAnonymousAccessStatus = async (domain: string): Promise<boolean | ServiceError> => sew(async () => { | ||
const org = await getOrgFromDomain(domain); | ||
if (!org) { | ||
return { | ||
statusCode: StatusCodes.NOT_FOUND, | ||
errorCode: ErrorCode.NOT_FOUND, | ||
message: "Organization not found", | ||
} satisfies ServiceError; | ||
} | ||
|
||
// If no metadata is set we don't try to parse it since it'll result in a parse error | ||
if (org.metadata === null) { | ||
return false; | ||
} | ||
|
||
const orgMetadata = getOrgMetadata(org); | ||
if (!orgMetadata) { | ||
return { | ||
statusCode: StatusCodes.INTERNAL_SERVER_ERROR, | ||
errorCode: ErrorCode.INVALID_ORG_METADATA, | ||
message: "Invalid organization metadata", | ||
} satisfies ServiceError; | ||
} | ||
|
||
return !!orgMetadata.anonymousAccessEnabled; | ||
}); | ||
|
||
export const setAnonymousAccessStatus = async (domain: string, enabled: boolean): Promise<ServiceError | boolean> => sew(async () => { | ||
return await withAuth(async (userId) => { | ||
return await withOrgMembership(userId, domain, async ({ org }) => { | ||
const hasAnonymousAccessEntitlement = hasEntitlement("anonymous-access"); | ||
if (!hasAnonymousAccessEntitlement) { | ||
const plan = getPlan(); | ||
console.error(`Anonymous access isn't supported in your current plan: ${plan}. For support, contact ${SOURCEBOT_SUPPORT_EMAIL}.`); | ||
return { | ||
statusCode: StatusCodes.FORBIDDEN, | ||
errorCode: ErrorCode.INSUFFICIENT_PERMISSIONS, | ||
message: "Anonymous access is not supported in your current plan", | ||
} satisfies ServiceError; | ||
} | ||
|
||
const currentMetadata = getOrgMetadata(org); | ||
const mergedMetadata = { | ||
...(currentMetadata ?? {}), | ||
anonymousAccessEnabled: enabled, | ||
}; | ||
|
||
await prisma.org.update({ | ||
where: { | ||
id: org.id, | ||
}, | ||
data: { | ||
metadata: mergedMetadata, | ||
}, | ||
}); | ||
|
||
return true; | ||
}, /* minRequiredRole = */ OrgRole.OWNER); | ||
}); | ||
}); | ||
|
||
////// Helpers /////// | ||
|
Uh oh!
There was an error while loading. Please reload this page.