mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2026-06-22 10:02:15 +00:00
chore: refactor REST API permission check
This commit is contained in:
parent
172e1d75cf
commit
b296496356
36 changed files with 1068 additions and 427 deletions
|
|
@ -11,10 +11,11 @@ import (
|
||||||
auth_model "forgejo.org/models/auth"
|
auth_model "forgejo.org/models/auth"
|
||||||
"forgejo.org/modules/log"
|
"forgejo.org/modules/log"
|
||||||
"forgejo.org/modules/setting"
|
"forgejo.org/modules/setting"
|
||||||
|
apiv1_permissions "forgejo.org/routers/api/v1/permissions"
|
||||||
|
apiv1_permissions_tests "forgejo.org/routers/api/v1/permissions/tests"
|
||||||
"forgejo.org/routers/common"
|
"forgejo.org/routers/common"
|
||||||
"forgejo.org/services/auth"
|
"forgejo.org/services/auth"
|
||||||
auth_method "forgejo.org/services/auth/method"
|
auth_method "forgejo.org/services/auth/method"
|
||||||
"forgejo.org/services/authz"
|
|
||||||
"forgejo.org/services/context"
|
"forgejo.org/services/context"
|
||||||
|
|
||||||
"github.com/go-chi/cors"
|
"github.com/go-chi/cors"
|
||||||
|
|
@ -38,7 +39,7 @@ func Middlewares() (stack []any) {
|
||||||
checkDeprecatedAuthMethods,
|
checkDeprecatedAuthMethods,
|
||||||
// Get user from session if logged in.
|
// Get user from session if logged in.
|
||||||
apiAuthentication(buildAuthGroup()),
|
apiAuthentication(buildAuthGroup()),
|
||||||
apiAuthorization,
|
apiAuthorization(),
|
||||||
verifyAuthWithOptions(&common.VerifyOptions{
|
verifyAuthWithOptions(&common.VerifyOptions{
|
||||||
SignInRequired: setting.Service.RequireSignInView,
|
SignInRequired: setting.Service.RequireSignInView,
|
||||||
}),
|
}),
|
||||||
|
|
@ -97,28 +98,10 @@ func apiAuthentication(authMethod auth.Method) func(*context.APIContext) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func apiAuthorization(ctx *context.APIContext) {
|
func apiAuthorization() func(ctx *context.APIContext) {
|
||||||
if hasScope, scope := ctx.Authentication.Scope().Get(); hasScope {
|
apiv1_permissions_tests.RecordSignature(apiv1_permissions.APIAuthorization)
|
||||||
publicOnly, err := scope.PublicOnly()
|
return func(ctx *context.APIContext) {
|
||||||
if err != nil {
|
apiv1_permissions.APIAuthorization(ctx)
|
||||||
ctx.Error(http.StatusForbidden, "tokenRequiresScope", "parsing public resource scope failed: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx.PublicOnly = publicOnly
|
|
||||||
}
|
|
||||||
|
|
||||||
reducer := ctx.Authentication.Reducer()
|
|
||||||
if reducer != nil {
|
|
||||||
ctx.Reducer = reducer
|
|
||||||
} else {
|
|
||||||
// No Reducer will be populated if the auth method wasn't an PAT. In this case, we populate `ctx.Reducer` so no
|
|
||||||
// nil checks are needed, and we respect the scope `PublicOnly()` so that it it's safe to just rely on
|
|
||||||
// `ctx.Reducer` to account for public-only access:
|
|
||||||
if ctx.PublicOnly {
|
|
||||||
ctx.Reducer = &authz.PublicReposAuthorizationReducer{}
|
|
||||||
} else {
|
|
||||||
ctx.Reducer = &authz.AllAccessAuthorizationReducer{}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -191,15 +191,13 @@ func checkPermission(check func(ctx apiv1_permissions.Context)) func(*context.AP
|
||||||
}
|
}
|
||||||
|
|
||||||
// must be used within a group with a call to commentAssignment() to set ctx.Comment
|
// must be used within a group with a call to commentAssignment() to set ctx.Comment
|
||||||
func ReqValidCommentID(ctx Context, comment *issues_model.Comment) {
|
func reqValidCommentID() func(*context.APIContext) {
|
||||||
if comment.Issue == nil || comment.Issue.RepoID != ctx.GetRepository().ID {
|
apiv1_permissions_tests.RecordSignature(apiv1_permissions.ReqValidCommentID)
|
||||||
ctx.NotFound()
|
return func(ctx *context.APIContext) {
|
||||||
return
|
if ctx.Comment == nil {
|
||||||
}
|
panic("reqValidCommentID requires commentAssignment to be called first")
|
||||||
|
}
|
||||||
if !ctx.GetPermission().CanReadIssuesOrPulls(comment.Issue.IsPull) {
|
apiv1_permissions.ReqValidCommentID(ctx, ctx.Comment)
|
||||||
ctx.NotFound()
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -228,64 +226,25 @@ func commentAssignment(idParam string) func(ctx *context.APIContext) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func ReqPackageAccess(ctx Context, accessMode perm.AccessMode) {
|
func reqPackageAccess(accessMode perm.AccessMode) func(ctx *context.APIContext) {
|
||||||
if ctx.GetPackageAccessMode() < accessMode && !IsUserSiteAdmin(ctx) {
|
apiv1_permissions_tests.RecordSignature(apiv1_permissions.ReqPackageAccess, accessMode)
|
||||||
ctx.Error(http.StatusForbidden, "reqPackageAccess", "user should have specific permission or be a site admin")
|
return func(ctx *context.APIContext) {
|
||||||
return
|
apiv1_permissions.ReqPackageAccess(ctx, accessMode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func CheckTokenPublicOnly(ctx Context, user, org, packageOwner *user_model.User) {
|
func checkTokenPublicOnly() func(*context.APIContext) {
|
||||||
if !ctx.GetPublicOnly() {
|
apiv1_permissions_tests.RecordSignature(apiv1_permissions.CheckTokenPublicOnly)
|
||||||
return
|
return func(ctx *context.APIContext) {
|
||||||
}
|
var packageOwner *user_model.User
|
||||||
|
if ctx.Package != nil {
|
||||||
requiredScopeCategories := ctx.GetRequiredScopeCategories()
|
packageOwner = ctx.Package.Owner
|
||||||
if len(requiredScopeCategories) == 0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// public Only permission check
|
|
||||||
switch {
|
|
||||||
case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryRepository):
|
|
||||||
if ctx.GetRepository() != nil && ctx.GetRepository().IsPrivate {
|
|
||||||
ctx.Error(http.StatusForbidden, "reqToken", "token scope is limited to public repos")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryIssue):
|
var org *user_model.User
|
||||||
if ctx.GetRepository() != nil && ctx.GetRepository().IsPrivate {
|
if ctx.Org != nil && ctx.Org.Organization != nil {
|
||||||
ctx.Error(http.StatusForbidden, "reqToken", "token scope is limited to public issues")
|
org = ctx.Org.Organization.AsUser()
|
||||||
return
|
|
||||||
}
|
|
||||||
case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryOrganization):
|
|
||||||
if org != nil && org.Visibility != api.VisibleTypePublic {
|
|
||||||
ctx.Error(http.StatusForbidden, "reqToken", "token scope is limited to public orgs")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if user != nil && user.IsOrganization() && user.Visibility != api.VisibleTypePublic {
|
|
||||||
ctx.Error(http.StatusForbidden, "reqToken", "token scope is limited to public orgs")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryUser):
|
|
||||||
if user != nil && user.IsUser() && user.Visibility != api.VisibleTypePublic {
|
|
||||||
ctx.Error(http.StatusForbidden, "reqToken", "token scope is limited to public users")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryActivityPub):
|
|
||||||
if user != nil && user.IsUser() && user.Visibility != api.VisibleTypePublic {
|
|
||||||
ctx.Error(http.StatusForbidden, "reqToken", "token scope is limited to public activitypub")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryNotification):
|
|
||||||
if ctx.GetRepository() != nil && ctx.GetRepository().IsPrivate {
|
|
||||||
ctx.Error(http.StatusForbidden, "reqToken", "token scope is limited to public notifications")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryPackage):
|
|
||||||
if packageOwner != nil && packageOwner.Visibility.IsPrivate() {
|
|
||||||
ctx.Error(http.StatusForbidden, "reqToken", "token scope is limited to public packages")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
apiv1_permissions.CheckTokenPublicOnly(ctx, ctx.ContextUser, org, packageOwner)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -300,285 +259,123 @@ func requiredScopeLevel(ctx *context.APIContext) auth_model.AccessTokenScopeLeve
|
||||||
return requiredScopeLevel
|
return requiredScopeLevel
|
||||||
}
|
}
|
||||||
|
|
||||||
func TokenRequiresScopes(ctx Context, requiredScopeCategories []auth_model.AccessTokenScopeCategory, requiredScopeLevel auth_model.AccessTokenScopeLevel) {
|
func tokenRequiresScopes(requiredScopeCategories ...auth_model.AccessTokenScopeCategory) func(*context.APIContext) {
|
||||||
// no scope required
|
apiv1_permissions_tests.RecordSignature(apiv1_permissions.TokenRequiresScopes, requiredScopeCategories)
|
||||||
if len(requiredScopeCategories) == 0 {
|
return func(ctx *context.APIContext) {
|
||||||
return
|
apiv1_permissions.TokenRequiresScopes(ctx, requiredScopeCategories, requiredScopeLevel(ctx))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Need OAuth2 token to be present.
|
|
||||||
hasScope, scope := ctx.GetAuthentication().Scope().Get()
|
|
||||||
if !hasScope {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// get the required scope for the given access level and category
|
|
||||||
requiredScopes := auth_model.GetRequiredScopes(requiredScopeLevel, requiredScopeCategories...)
|
|
||||||
allow, err := scope.HasScope(requiredScopes...)
|
|
||||||
if err != nil {
|
|
||||||
ctx.Error(http.StatusForbidden, "tokenRequiresScope", "checking scope failed: "+err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if !allow {
|
|
||||||
ctx.Error(http.StatusForbidden, "tokenRequiresScope", fmt.Sprintf("token does not have at least one of required scope(s): %v", requiredScopes))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.SetRequiredScopeCategories(requiredScopeCategories)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Middleware that dynamically checks either the organization or user scope, depending on the owner type of the
|
// Middleware that dynamically checks either the organization or user scope, depending on the owner type of the
|
||||||
// repository (requires `repoAssignment()` middleware to be used before this).
|
// repository (requires `repoAssignment()` middleware to be used before this).
|
||||||
func TokenRequiresRepoOwnerScope(ctx Context, owner *user_model.User, requiredScopeLevel auth_model.AccessTokenScopeLevel) {
|
func tokenRequiresRepoOwnerScope() func(*context.APIContext) {
|
||||||
var category auth_model.AccessTokenScopeCategory
|
apiv1_permissions_tests.RecordSignature(apiv1_permissions.TokenRequiresRepoOwnerScope)
|
||||||
if owner.IsOrganization() {
|
return func(ctx *context.APIContext) {
|
||||||
category = auth_model.AccessTokenScopeCategoryOrganization
|
apiv1_permissions.TokenRequiresRepoOwnerScope(ctx, ctx.Repo.Owner, requiredScopeLevel(ctx))
|
||||||
} else {
|
|
||||||
category = auth_model.AccessTokenScopeCategoryUser
|
|
||||||
}
|
}
|
||||||
TokenRequiresScopes(ctx, []auth_model.AccessTokenScopeCategory{category}, requiredScopeLevel)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Contexter middleware already checks token for user sign in process.
|
// Contexter middleware already checks token for user sign in process.
|
||||||
func ReqToken(ctx Context) {
|
func reqToken() func(ctx *context.APIContext) {
|
||||||
// If actions token is present
|
return checkPermission(apiv1_permissions.ReqToken)
|
||||||
if ctx.GetAuthentication().ActionsTaskID().Has() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if ctx.GetIsSigned() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
ctx.Error(http.StatusUnauthorized, "reqToken", "token is required")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func ReqExploreSignIn(ctx Context) {
|
func reqExploreSignIn() func(ctx *context.APIContext) {
|
||||||
if (setting.Service.RequireSignInView || setting.Service.Explore.RequireSigninView) && !ctx.GetIsSigned() {
|
return checkPermission(apiv1_permissions.ReqExploreSignIn)
|
||||||
ctx.Error(http.StatusUnauthorized, "reqExploreSignIn", "you must be signed in to search for users")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func ReqUsersExploreEnabled(ctx Context) {
|
func reqUsersExploreEnabled() func(ctx *context.APIContext) {
|
||||||
if setting.Service.Explore.DisableUsersPage {
|
return checkPermission(apiv1_permissions.ReqUsersExploreEnabled)
|
||||||
ctx.NotFound()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func ReqBasicOrRevProxyAuth(ctx Context) {
|
func reqBasicOrRevProxyAuth() func(ctx *context.APIContext) {
|
||||||
if ctx.GetIsSigned() && setting.Service.EnableReverseProxyAuthAPI && ctx.GetAuthentication().IsReverseProxyAuthentication() {
|
return checkPermission(apiv1_permissions.ReqBasicOrRevProxyAuth)
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Require basic authorization method to be used and that basic
|
|
||||||
// authorization used password login to verify the user.
|
|
||||||
if !ctx.GetAuthentication().IsPasswordAuthentication() {
|
|
||||||
ctx.Error(http.StatusUnauthorized, "reqBasicAuth", "auth method not allowed")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func ReqSiteAdmin(ctx Context) {
|
func reqSiteAdmin() func(ctx *context.APIContext) {
|
||||||
if !IsUserSiteAdmin(ctx) {
|
return checkPermission(apiv1_permissions.ReqSiteAdmin)
|
||||||
ctx.Error(http.StatusForbidden, "reqSiteAdmin", "user should be the site admin")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// reqOwner requires that the current user is either the owner of the repository or an administrator. If one or more
|
// reqOwner requires that the current user is either the owner of the repository or an administrator. If one or more
|
||||||
// unitTypes are given, it also requires that at least one the respective unitTypes is enabled.
|
// unitTypes are given, it also requires that at least one the respective unitTypes is enabled.
|
||||||
func ReqOwner(ctx Context, unitTypes []unit.Type) {
|
func reqOwner(unitTypes ...unit.Type) func(ctx *context.APIContext) {
|
||||||
if len(unitTypes) > 0 && !slices.ContainsFunc(unitTypes, func(unitType unit.Type) bool {
|
apiv1_permissions_tests.RecordSignature(apiv1_permissions.ReqOwner, unitTypes)
|
||||||
return ctx.GetRepository().UnitEnabled(ctx.GetContext(), unitType)
|
return func(ctx *context.APIContext) {
|
||||||
}) {
|
apiv1_permissions.ReqOwner(ctx, unitTypes)
|
||||||
ctx.NotFound()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !ctx.GetPermission().IsOwner() && !IsUserSiteAdmin(ctx) {
|
|
||||||
ctx.Error(http.StatusForbidden, "reqOwner", "user should be the owner of the repo")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// reqSelfOrAdmin doer should be the same as the contextUser or site admin
|
// reqSelfOrAdmin doer should be the same as the contextUser or site admin
|
||||||
func ReqSelfOrAdmin(ctx Context) {
|
func reqSelfOrAdmin() func(ctx *context.APIContext) {
|
||||||
getName := func(user *user_model.User) string {
|
return checkPermission(apiv1_permissions.ReqSelfOrAdmin)
|
||||||
if user == nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return user.Name
|
|
||||||
}
|
|
||||||
|
|
||||||
if !IsUserSiteAdmin(ctx) && getName(ctx.GetUser()) != getName(ctx.GetDoer()) {
|
|
||||||
ctx.Error(http.StatusForbidden, "reqSelfOrAdmin", "doer should be the site admin or be same as the contextUser")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// reqAdmin user should be an owner or a collaborator with admin write of a repository, or site admin. If one or more
|
// reqAdmin user should be an owner or a collaborator with admin write of a repository, or site admin. If one or more
|
||||||
// unitTypes are given, it also requires that at least one the respective unitTypes is enabled.
|
// unitTypes are given, it also requires that at least one the respective unitTypes is enabled.
|
||||||
func ReqAdmin(ctx Context, unitTypes []unit.Type) {
|
func reqAdmin(unitTypes ...unit.Type) func(ctx *context.APIContext) {
|
||||||
if len(unitTypes) > 0 && !slices.ContainsFunc(unitTypes, func(unitType unit.Type) bool {
|
apiv1_permissions_tests.RecordSignature(apiv1_permissions.ReqAdmin, unitTypes)
|
||||||
return ctx.GetRepository().UnitEnabled(ctx.GetContext(), unitType)
|
return func(ctx *context.APIContext) {
|
||||||
}) {
|
apiv1_permissions.ReqAdmin(ctx, unitTypes)
|
||||||
ctx.NotFound()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !IsUserRepoAdmin(ctx) && !IsUserSiteAdmin(ctx) {
|
|
||||||
ctx.Error(http.StatusForbidden, "reqAdmin", "user should be an owner or a collaborator with admin write of a repository")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// reqRepoWriter requires that the current user has permission to write to a repository or that it is an administrator.
|
// reqRepoWriter requires that the current user has permission to write to a repository or that it is an administrator.
|
||||||
// One or more unitTypes have to be specified, and at least one of them has to be enabled.
|
// One or more unitTypes have to be specified, and at least one of them has to be enabled.
|
||||||
func ReqRepoWriter(ctx Context, unitTypes []unit.Type) {
|
func reqRepoWriter(unitTypes ...unit.Type) func(ctx *context.APIContext) {
|
||||||
if !slices.ContainsFunc(unitTypes, func(unitType unit.Type) bool {
|
apiv1_permissions_tests.RecordSignature(apiv1_permissions.ReqRepoWriter, unitTypes)
|
||||||
return ctx.GetRepository().UnitEnabled(ctx.GetContext(), unitType)
|
return func(ctx *context.APIContext) {
|
||||||
}) {
|
apiv1_permissions.ReqRepoWriter(ctx, unitTypes)
|
||||||
ctx.NotFound()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !IsUserRepoWriter(ctx, unitTypes) && !IsUserRepoAdmin(ctx) && !IsUserSiteAdmin(ctx) {
|
|
||||||
ctx.Error(http.StatusForbidden, "reqRepoWriter", "user should have a permission to write to a repo")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// reqRepoBranchWriter user should have a permission to write to a branch, or be a site admin
|
// reqRepoBranchWriter user should have a permission to write to a branch, or be a site admin
|
||||||
func ReqRepoBranchWriter(ctx Context, branch string) {
|
func reqRepoBranchWriter() func(*context.APIContext) {
|
||||||
if !issues_model.CanMaintainerWriteToBranch(ctx.GetContext(), *ctx.GetPermission(), branch, ctx.GetDoer()) && !IsUserSiteAdmin(ctx) {
|
apiv1_permissions_tests.RecordSignature(apiv1_permissions.ReqRepoBranchWriter)
|
||||||
ctx.Error(http.StatusForbidden, "reqRepoBranchWriter", "user should have a permission to write to this branch")
|
return func(ctx *context.APIContext) {
|
||||||
|
options, ok := web.GetForm(ctx).(api.FileOptionInterface)
|
||||||
|
if !ok {
|
||||||
|
ctx.Error(http.StatusForbidden, "reqRepoBranchWriter", "user should have a permission to write to this branch")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
apiv1_permissions.ReqRepoBranchWriter(ctx, options.Branch())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// reqRepoReader user should have specific read permission or be a repo admin or a site admin
|
// reqRepoReader user should have specific read permission or be a repo admin or a site admin
|
||||||
func ReqRepoReader(ctx Context, unitType unit.Type) {
|
func reqRepoReader(unitType unit.Type) func(*context.APIContext) {
|
||||||
if !ctx.GetRepository().UnitEnabled(ctx.GetContext(), unitType) {
|
apiv1_permissions_tests.RecordSignature(apiv1_permissions.ReqRepoReader, unitType)
|
||||||
ctx.NotFound()
|
return func(ctx *context.APIContext) {
|
||||||
return
|
apiv1_permissions.ReqRepoReader(ctx, unitType)
|
||||||
}
|
|
||||||
if !ctx.GetPermission().CanRead(unitType) && !IsUserRepoAdmin(ctx) && !IsUserSiteAdmin(ctx) {
|
|
||||||
ctx.Error(http.StatusForbidden, "reqRepoReader", "user should have specific read permission or be a repo admin or a site admin")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// reqAnyRepoReader user should have any permission to read repository or permissions of site admin
|
// reqAnyRepoReader user should have any permission to read repository or permissions of site admin
|
||||||
func ReqAnyRepoReader(ctx Context) {
|
func reqAnyRepoReader() func(ctx *context.APIContext) {
|
||||||
if !ctx.GetPermission().HasAccess() && !IsUserSiteAdmin(ctx) {
|
return checkPermission(apiv1_permissions.ReqAnyRepoReader)
|
||||||
ctx.Error(http.StatusForbidden, "reqAnyRepoReader", "user should have any permission to read repository or permissions of site admin")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// reqOrgOwnership user should be an organization owner, or a site admin
|
// reqOrgOwnership user should be an organization owner, or a site admin
|
||||||
func ReqOrgOwnership(ctx Context) {
|
func reqOrgOwnership() func(ctx *context.APIContext) {
|
||||||
if IsUserSiteAdmin(ctx) {
|
return checkPermission(apiv1_permissions.ReqOrgOwnership)
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var orgID int64
|
|
||||||
if ctx.GetOrg() != nil {
|
|
||||||
orgID = ctx.GetOrg().ID
|
|
||||||
} else if ctx.GetTeam() != nil {
|
|
||||||
orgID = ctx.GetTeam().OrgID
|
|
||||||
} else {
|
|
||||||
ctx.Error(http.StatusInternalServerError, "", "reqOrgOwnership: unprepared context")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
isOwner, err := organization.IsOrganizationOwner(ctx.GetContext(), orgID, ctx.GetDoer().ID)
|
|
||||||
if err != nil {
|
|
||||||
ctx.Error(http.StatusInternalServerError, "IsOrganizationOwner", err)
|
|
||||||
return
|
|
||||||
} else if !isOwner {
|
|
||||||
if ctx.GetOrg() != nil {
|
|
||||||
ctx.Error(http.StatusForbidden, "", "Must be an organization owner")
|
|
||||||
} else {
|
|
||||||
ctx.NotFound()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// reqTeamMembership user should be an team member, or a site admin
|
// reqTeamMembership user should be an team member, or a site admin
|
||||||
func ReqTeamMembership(ctx Context) {
|
func reqTeamMembership() func(ctx *context.APIContext) {
|
||||||
if IsUserSiteAdmin(ctx) {
|
return checkPermission(apiv1_permissions.ReqTeamMembership)
|
||||||
return
|
|
||||||
}
|
|
||||||
if ctx.GetTeam() == nil {
|
|
||||||
ctx.Error(http.StatusInternalServerError, "", "reqTeamMembership: unprepared context")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
orgID := ctx.GetTeam().OrgID
|
|
||||||
isOwner, err := organization.IsOrganizationOwner(ctx.GetContext(), orgID, ctx.GetDoer().ID)
|
|
||||||
if err != nil {
|
|
||||||
ctx.Error(http.StatusInternalServerError, "IsOrganizationOwner", err)
|
|
||||||
return
|
|
||||||
} else if isOwner {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if isTeamMember, err := organization.IsTeamMember(ctx.GetContext(), orgID, ctx.GetTeam().ID, ctx.GetDoer().ID); err != nil {
|
|
||||||
ctx.Error(http.StatusInternalServerError, "IsTeamMember", err)
|
|
||||||
return
|
|
||||||
} else if !isTeamMember {
|
|
||||||
isOrgMember, err := organization.IsOrganizationMember(ctx.GetContext(), orgID, ctx.GetDoer().ID)
|
|
||||||
if err != nil {
|
|
||||||
ctx.Error(http.StatusInternalServerError, "IsOrganizationMember", err)
|
|
||||||
} else if isOrgMember {
|
|
||||||
ctx.Error(http.StatusForbidden, "", "Must be a team member")
|
|
||||||
} else {
|
|
||||||
ctx.NotFound()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// reqOrgMembership user should be an organization member, or a site admin
|
// reqOrgMembership user should be an organization member, or a site admin
|
||||||
func ReqOrgMembership(ctx Context) {
|
func reqOrgMembership() func(ctx *context.APIContext) {
|
||||||
if IsUserSiteAdmin(ctx) {
|
return checkPermission(apiv1_permissions.ReqOrgMembership)
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var orgID int64
|
|
||||||
if ctx.GetOrg() != nil {
|
|
||||||
orgID = ctx.GetOrg().ID
|
|
||||||
} else if ctx.GetTeam() != nil {
|
|
||||||
orgID = ctx.GetTeam().OrgID
|
|
||||||
} else {
|
|
||||||
ctx.Error(http.StatusInternalServerError, "", "reqOrgMembership: unprepared context")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if isMember, err := organization.IsOrganizationMember(ctx.GetContext(), orgID, ctx.GetDoer().ID); err != nil {
|
|
||||||
ctx.Error(http.StatusInternalServerError, "IsOrganizationMember", err)
|
|
||||||
return
|
|
||||||
} else if !isMember {
|
|
||||||
if ctx.GetOrg() != nil {
|
|
||||||
ctx.Error(http.StatusForbidden, "", "Must be an organization member")
|
|
||||||
} else {
|
|
||||||
ctx.NotFound()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func ReqGitHook(ctx Context) {
|
func reqGitHook() func(ctx *context.APIContext) {
|
||||||
if !ctx.GetDoer().CanEditGitHook() {
|
return checkPermission(apiv1_permissions.ReqGitHook)
|
||||||
ctx.Error(http.StatusForbidden, "", "must be allowed to edit Git hooks")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// reqWebhooksEnabled requires webhooks to be enabled by admin.
|
// reqWebhooksEnabled requires webhooks to be enabled by admin.
|
||||||
func ReqWebhooksEnabled(ctx Context) {
|
func reqWebhooksEnabled() func(ctx *context.APIContext) {
|
||||||
if setting.DisableWebhooks {
|
return checkPermission(apiv1_permissions.ReqWebhooksEnabled)
|
||||||
ctx.Error(http.StatusForbidden, "", "webhooks disabled by administrator")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func orgAssignment(args ...bool) func(ctx *context.APIContext) {
|
func orgAssignment(args ...bool) func(ctx *context.APIContext) {
|
||||||
|
|
@ -630,115 +427,35 @@ func orgAssignment(args ...bool) func(ctx *context.APIContext) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func MustEnableIssues(ctx Context) {
|
func mustEnableIssues() func(ctx *context.APIContext) {
|
||||||
if !ctx.GetPermission().CanRead(unit.TypeIssues) {
|
return checkPermission(apiv1_permissions.MustEnableIssues)
|
||||||
if log.IsTrace() {
|
}
|
||||||
if ctx.GetIsSigned() {
|
|
||||||
log.Trace("Permission Denied: User %-v cannot read %-v in Repo %-v\n"+
|
func mustEnableIssuesOrPulls() func(ctx *context.APIContext) {
|
||||||
"User in Repo has Permissions: %-+v",
|
return checkPermission(apiv1_permissions.MustEnableIssuesOrPulls)
|
||||||
ctx.GetDoer(),
|
}
|
||||||
unit.TypeIssues,
|
|
||||||
ctx.GetRepository(),
|
func mustAllowPulls() func(ctx *context.APIContext) {
|
||||||
ctx.GetPermission())
|
return checkPermission(apiv1_permissions.MustAllowPulls)
|
||||||
} else {
|
}
|
||||||
log.Trace("Permission Denied: Anonymous user cannot read %-v in Repo %-v\n"+
|
|
||||||
"Anonymous user in Repo has Permissions: %-+v",
|
func mustEnableLocalIssuesIfIsIssue() func(*context.APIContext) {
|
||||||
unit.TypeIssues,
|
apiv1_permissions_tests.RecordSignature(apiv1_permissions.MustEnableLocalIssuesIfIsIssue)
|
||||||
ctx.GetRepository(),
|
return func(ctx *context.APIContext) {
|
||||||
ctx.GetPermission())
|
apiv1_permissions.MustEnableLocalIssuesIfIsIssue(ctx, ctx.ParamsInt64(":index"))
|
||||||
}
|
|
||||||
}
|
|
||||||
ctx.NotFound()
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func MustAllowPulls(ctx Context) {
|
func mustEnableWiki() func(ctx *context.APIContext) {
|
||||||
if !ctx.GetRepository().CanEnablePulls() || !ctx.GetPermission().CanRead(unit.TypePullRequests) {
|
return checkPermission(apiv1_permissions.MustEnableWiki)
|
||||||
if ctx.GetRepository().CanEnablePulls() && log.IsTrace() {
|
|
||||||
if ctx.GetIsSigned() {
|
|
||||||
log.Trace("Permission Denied: User %-v cannot read %-v in Repo %-v\n"+
|
|
||||||
"User in Repo has Permissions: %-+v",
|
|
||||||
ctx.GetDoer(),
|
|
||||||
unit.TypePullRequests,
|
|
||||||
ctx.GetRepository(),
|
|
||||||
ctx.GetPermission())
|
|
||||||
} else {
|
|
||||||
log.Trace("Permission Denied: Anonymous user cannot read %-v in Repo %-v\n"+
|
|
||||||
"Anonymous user in Repo has Permissions: %-+v",
|
|
||||||
unit.TypePullRequests,
|
|
||||||
ctx.GetRepository(),
|
|
||||||
ctx.GetPermission())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ctx.NotFound()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func MustEnableIssuesOrPulls(ctx Context) {
|
func mustNotBeArchived() func(ctx *context.APIContext) {
|
||||||
if !ctx.GetPermission().CanRead(unit.TypeIssues) &&
|
return checkPermission(apiv1_permissions.MustNotBeArchived)
|
||||||
(!ctx.GetRepository().CanEnablePulls() || !ctx.GetPermission().CanRead(unit.TypePullRequests)) {
|
|
||||||
if ctx.GetRepository().CanEnablePulls() && log.IsTrace() {
|
|
||||||
if ctx.GetIsSigned() {
|
|
||||||
log.Trace("Permission Denied: User %-v cannot read %-v and %-v in Repo %-v\n"+
|
|
||||||
"User in Repo has Permissions: %-+v",
|
|
||||||
ctx.GetDoer(),
|
|
||||||
unit.TypeIssues,
|
|
||||||
unit.TypePullRequests,
|
|
||||||
ctx.GetRepository(),
|
|
||||||
ctx.GetPermission())
|
|
||||||
} else {
|
|
||||||
log.Trace("Permission Denied: Anonymous user cannot read %-v and %-v in Repo %-v\n"+
|
|
||||||
"Anonymous user in Repo has Permissions: %-+v",
|
|
||||||
unit.TypeIssues,
|
|
||||||
unit.TypePullRequests,
|
|
||||||
ctx.GetRepository(),
|
|
||||||
ctx.GetPermission())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ctx.NotFound()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func MustEnableLocalIssuesIfIsIssue(ctx Context, index int64) {
|
func mustEnableAttachments() func(ctx *context.APIContext) {
|
||||||
if ctx.GetRepository().UnitEnabled(ctx.GetContext(), unit.TypeIssues) {
|
return checkPermission(apiv1_permissions.MustEnableAttachments)
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
issue, err := issues_model.GetIssueByIndex(ctx.GetContext(), ctx.GetRepository().ID, index)
|
|
||||||
if err != nil {
|
|
||||||
if issues_model.IsErrIssueNotExist(err) {
|
|
||||||
ctx.NotFound()
|
|
||||||
} else {
|
|
||||||
ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !issue.IsPull {
|
|
||||||
ctx.NotFound()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func MustEnableWiki(ctx Context) {
|
|
||||||
if !(ctx.GetPermission().CanRead(unit.TypeWiki)) {
|
|
||||||
ctx.NotFound()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func MustNotBeArchived(ctx Context) {
|
|
||||||
if ctx.GetRepository().IsArchived {
|
|
||||||
ctx.Error(http.StatusLocked, "RepoArchived", fmt.Errorf("%s is archived", ctx.GetRepository().LogString()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func MustEnableAttachments(ctx Context) {
|
|
||||||
if !setting.Attachment.Enabled {
|
|
||||||
ctx.NotFound()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// bind binding an obj to a func(ctx *context.APIContext)
|
// bind binding an obj to a func(ctx *context.APIContext)
|
||||||
|
|
@ -754,22 +471,8 @@ func bind[T any](_ T) any {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func IndividualPermsChecker(ctx Context) {
|
func individualPermsChecker() func(ctx *context.APIContext) {
|
||||||
// org permissions have been checked in context.OrgAssignment(), but individual permissions haven't been checked.
|
return checkPermission(apiv1_permissions.IndividualPermsChecker)
|
||||||
if ctx.GetUser().IsIndividual() {
|
|
||||||
switch ctx.GetUser().Visibility {
|
|
||||||
case api.VisibleTypePrivate:
|
|
||||||
if ctx.GetDoer() == nil || (ctx.GetUser().ID != ctx.GetDoer().ID && !IsUserSiteAdmin(ctx)) {
|
|
||||||
ctx.NotFound("Visit Project", nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
case api.VisibleTypeLimited:
|
|
||||||
if ctx.GetDoer() == nil {
|
|
||||||
ctx.NotFound("Visit Project", nil)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Routes registers all v1 APIs routes to web application.
|
// Routes registers all v1 APIs routes to web application.
|
||||||
|
|
|
||||||
35
routers/api/v1/permissions/api_authorization.go
Normal file
35
routers/api/v1/permissions/api_authorization.go
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package permissions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"forgejo.org/services/authz"
|
||||||
|
)
|
||||||
|
|
||||||
|
func APIAuthorization(ctx Context) {
|
||||||
|
if hasScope, scope := ctx.GetAuthentication().Scope().Get(); hasScope {
|
||||||
|
publicOnly, err := scope.PublicOnly()
|
||||||
|
if err != nil {
|
||||||
|
ctx.Error(http.StatusForbidden, "tokenRequiresScope", "parsing public resource scope failed: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.SetPublicOnly(publicOnly)
|
||||||
|
}
|
||||||
|
|
||||||
|
reducer := ctx.GetAuthentication().Reducer()
|
||||||
|
if reducer != nil {
|
||||||
|
ctx.SetReducer(reducer)
|
||||||
|
} else {
|
||||||
|
// No Reducer will be populated if the auth method wasn't an PAT. In this case, we populate `ctx.Reducer` so no
|
||||||
|
// nil checks are needed, and we respect the scope `PublicOnly()` so that it it's safe to just rely on
|
||||||
|
// `ctx.Reducer` to account for public-only access:
|
||||||
|
if ctx.GetPublicOnly() {
|
||||||
|
ctx.SetReducer(&authz.PublicReposAuthorizationReducer{})
|
||||||
|
} else {
|
||||||
|
ctx.SetReducer(&authz.AllAccessAuthorizationReducer{})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
66
routers/api/v1/permissions/check_token_public_only.go
Normal file
66
routers/api/v1/permissions/check_token_public_only.go
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package permissions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
auth_model "forgejo.org/models/auth"
|
||||||
|
user_model "forgejo.org/models/user"
|
||||||
|
api "forgejo.org/modules/structs"
|
||||||
|
)
|
||||||
|
|
||||||
|
func CheckTokenPublicOnly(ctx Context, user, org, packageOwner *user_model.User) {
|
||||||
|
if !ctx.GetPublicOnly() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
requiredScopeCategories := ctx.GetRequiredScopeCategories()
|
||||||
|
if len(requiredScopeCategories) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// public Only permission check
|
||||||
|
switch {
|
||||||
|
case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryRepository):
|
||||||
|
if ctx.GetRepository() != nil && ctx.GetRepository().IsPrivate {
|
||||||
|
ctx.Error(http.StatusForbidden, "reqToken", "token scope is limited to public repos")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryIssue):
|
||||||
|
if ctx.GetRepository() != nil && ctx.GetRepository().IsPrivate {
|
||||||
|
ctx.Error(http.StatusForbidden, "reqToken", "token scope is limited to public issues")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryOrganization):
|
||||||
|
if org != nil && org.Visibility != api.VisibleTypePublic {
|
||||||
|
ctx.Error(http.StatusForbidden, "reqToken", "token scope is limited to public orgs")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if user != nil && user.IsOrganization() && user.Visibility != api.VisibleTypePublic {
|
||||||
|
ctx.Error(http.StatusForbidden, "reqToken", "token scope is limited to public orgs")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryUser):
|
||||||
|
if user != nil && user.IsUser() && user.Visibility != api.VisibleTypePublic {
|
||||||
|
ctx.Error(http.StatusForbidden, "reqToken", "token scope is limited to public users")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryActivityPub):
|
||||||
|
if user != nil && user.IsUser() && user.Visibility != api.VisibleTypePublic {
|
||||||
|
ctx.Error(http.StatusForbidden, "reqToken", "token scope is limited to public activitypub")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryNotification):
|
||||||
|
if ctx.GetRepository() != nil && ctx.GetRepository().IsPrivate {
|
||||||
|
ctx.Error(http.StatusForbidden, "reqToken", "token scope is limited to public notifications")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case auth_model.ContainsCategory(requiredScopeCategories, auth_model.AccessTokenScopeCategoryPackage):
|
||||||
|
if packageOwner != nil && packageOwner.Visibility.IsPrivate() {
|
||||||
|
ctx.Error(http.StatusForbidden, "reqToken", "token scope is limited to public packages")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
routers/api/v1/permissions/helpers.go
Normal file
28
routers/api/v1/permissions/helpers.go
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package permissions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
"forgejo.org/models/unit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func IsUserSiteAdmin(ctx Context) bool {
|
||||||
|
if !ctx.GetReducer().AllowAdminOverride() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return ctx.GetIsSigned() && ctx.GetDoer().IsAdmin
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsUserRepoAdmin(ctx Context) bool {
|
||||||
|
if !ctx.GetReducer().AllowAdminOverride() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return ctx.GetPermission().IsAdmin()
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsUserRepoWriter(ctx Context, unitTypes []unit.Type) bool {
|
||||||
|
return slices.ContainsFunc(unitTypes, ctx.GetPermission().CanWrite)
|
||||||
|
}
|
||||||
26
routers/api/v1/permissions/individual_perms_checker.go
Normal file
26
routers/api/v1/permissions/individual_perms_checker.go
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package permissions
|
||||||
|
|
||||||
|
import (
|
||||||
|
api "forgejo.org/modules/structs"
|
||||||
|
)
|
||||||
|
|
||||||
|
func IndividualPermsChecker(ctx Context) {
|
||||||
|
// org permissions have been checked in context.OrgAssignment(), but individual permissions haven't been checked.
|
||||||
|
if ctx.GetUser().IsIndividual() {
|
||||||
|
switch ctx.GetUser().Visibility {
|
||||||
|
case api.VisibleTypePrivate:
|
||||||
|
if ctx.GetDoer() == nil || (ctx.GetUser().ID != ctx.GetDoer().ID && !IsUserSiteAdmin(ctx)) {
|
||||||
|
ctx.NotFound("Visit Project", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case api.VisibleTypeLimited:
|
||||||
|
if ctx.GetDoer() == nil {
|
||||||
|
ctx.NotFound("Visit Project", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
32
routers/api/v1/permissions/must_allow_pulls.go
Normal file
32
routers/api/v1/permissions/must_allow_pulls.go
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package permissions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"forgejo.org/models/unit"
|
||||||
|
"forgejo.org/modules/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
func MustAllowPulls(ctx Context) {
|
||||||
|
if !ctx.GetRepository().CanEnablePulls() || !ctx.GetPermission().CanRead(unit.TypePullRequests) {
|
||||||
|
if ctx.GetRepository().CanEnablePulls() && log.IsTrace() {
|
||||||
|
if ctx.GetIsSigned() {
|
||||||
|
log.Trace("Permission Denied: User %-v cannot read %-v in Repo %-v\n"+
|
||||||
|
"User in Repo has Permissions: %-+v",
|
||||||
|
ctx.GetDoer(),
|
||||||
|
unit.TypePullRequests,
|
||||||
|
ctx.GetRepository(),
|
||||||
|
ctx.GetPermission())
|
||||||
|
} else {
|
||||||
|
log.Trace("Permission Denied: Anonymous user cannot read %-v in Repo %-v\n"+
|
||||||
|
"Anonymous user in Repo has Permissions: %-+v",
|
||||||
|
unit.TypePullRequests,
|
||||||
|
ctx.GetRepository(),
|
||||||
|
ctx.GetPermission())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.NotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
15
routers/api/v1/permissions/must_enable_attachments.go
Normal file
15
routers/api/v1/permissions/must_enable_attachments.go
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package permissions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"forgejo.org/modules/setting"
|
||||||
|
)
|
||||||
|
|
||||||
|
func MustEnableAttachments(ctx Context) {
|
||||||
|
if !setting.Attachment.Enabled {
|
||||||
|
ctx.NotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
32
routers/api/v1/permissions/must_enable_issues.go
Normal file
32
routers/api/v1/permissions/must_enable_issues.go
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package permissions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"forgejo.org/models/unit"
|
||||||
|
"forgejo.org/modules/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
func MustEnableIssues(ctx Context) {
|
||||||
|
if !ctx.GetPermission().CanRead(unit.TypeIssues) {
|
||||||
|
if log.IsTrace() {
|
||||||
|
if ctx.GetIsSigned() {
|
||||||
|
log.Trace("Permission Denied: User %-v cannot read %-v in Repo %-v\n"+
|
||||||
|
"User in Repo has Permissions: %-+v",
|
||||||
|
ctx.GetDoer(),
|
||||||
|
unit.TypeIssues,
|
||||||
|
ctx.GetRepository(),
|
||||||
|
ctx.GetPermission())
|
||||||
|
} else {
|
||||||
|
log.Trace("Permission Denied: Anonymous user cannot read %-v in Repo %-v\n"+
|
||||||
|
"Anonymous user in Repo has Permissions: %-+v",
|
||||||
|
unit.TypeIssues,
|
||||||
|
ctx.GetRepository(),
|
||||||
|
ctx.GetPermission())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.NotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
34
routers/api/v1/permissions/must_enable_issues_or_pulls.go
Normal file
34
routers/api/v1/permissions/must_enable_issues_or_pulls.go
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package permissions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"forgejo.org/models/unit"
|
||||||
|
"forgejo.org/modules/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
func MustEnableIssuesOrPulls(ctx Context) {
|
||||||
|
if !ctx.GetPermission().CanRead(unit.TypeIssues) &&
|
||||||
|
(!ctx.GetRepository().CanEnablePulls() || !ctx.GetPermission().CanRead(unit.TypePullRequests)) {
|
||||||
|
if ctx.GetRepository().CanEnablePulls() && log.IsTrace() {
|
||||||
|
if ctx.GetIsSigned() {
|
||||||
|
log.Trace("Permission Denied: User %-v cannot read %-v and %-v in Repo %-v\n"+
|
||||||
|
"User in Repo has Permissions: %-+v",
|
||||||
|
ctx.GetDoer(),
|
||||||
|
unit.TypeIssues,
|
||||||
|
unit.TypePullRequests,
|
||||||
|
ctx.GetRepository(),
|
||||||
|
ctx.GetPermission())
|
||||||
|
} else {
|
||||||
|
log.Trace("Permission Denied: Anonymous user cannot read %-v and %-v in Repo %-v\n"+
|
||||||
|
"Anonymous user in Repo has Permissions: %-+v",
|
||||||
|
unit.TypeIssues,
|
||||||
|
unit.TypePullRequests,
|
||||||
|
ctx.GetRepository(),
|
||||||
|
ctx.GetPermission())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.NotFound()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package permissions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
issues_model "forgejo.org/models/issues"
|
||||||
|
"forgejo.org/models/unit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func MustEnableLocalIssuesIfIsIssue(ctx Context, index int64) {
|
||||||
|
if ctx.GetRepository().UnitEnabled(ctx.GetContext(), unit.TypeIssues) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
issue, err := issues_model.GetIssueByIndex(ctx.GetContext(), ctx.GetRepository().ID, index)
|
||||||
|
if err != nil {
|
||||||
|
if issues_model.IsErrIssueNotExist(err) {
|
||||||
|
ctx.NotFound()
|
||||||
|
} else {
|
||||||
|
ctx.Error(http.StatusInternalServerError, "GetIssueByIndex", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !issue.IsPull {
|
||||||
|
ctx.NotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
15
routers/api/v1/permissions/must_enable_wiki.go
Normal file
15
routers/api/v1/permissions/must_enable_wiki.go
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package permissions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"forgejo.org/models/unit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func MustEnableWiki(ctx Context) {
|
||||||
|
if !(ctx.GetPermission().CanRead(unit.TypeWiki)) {
|
||||||
|
ctx.NotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
15
routers/api/v1/permissions/must_not_be_archived.go
Normal file
15
routers/api/v1/permissions/must_not_be_archived.go
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package permissions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func MustNotBeArchived(ctx Context) {
|
||||||
|
if ctx.GetRepository().IsArchived {
|
||||||
|
ctx.Error(http.StatusLocked, "RepoArchived", fmt.Errorf("%s is archived", ctx.GetRepository().LogString()))
|
||||||
|
}
|
||||||
|
}
|
||||||
57
routers/api/v1/permissions/repo_access.go
Normal file
57
routers/api/v1/permissions/repo_access.go
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package permissions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
actions_model "forgejo.org/models/actions"
|
||||||
|
"forgejo.org/models/perm"
|
||||||
|
access_model "forgejo.org/models/perm/access"
|
||||||
|
"forgejo.org/models/unit"
|
||||||
|
user_model "forgejo.org/models/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
func RepoAccess(ctx Context) {
|
||||||
|
if ctx.GetDoer() != nil && ctx.GetDoer().ID == user_model.ActionsUserID && ctx.GetAuthentication().ActionsTaskID().Has() {
|
||||||
|
_, taskID := ctx.GetAuthentication().ActionsTaskID().Get()
|
||||||
|
task, err := actions_model.GetTaskByID(ctx.GetContext(), taskID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Error(http.StatusInternalServerError, "actions_model.GetTaskByID", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if task.RepoID != ctx.GetRepository().ID {
|
||||||
|
ctx.NotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if task.IsForkPullRequest {
|
||||||
|
ctx.GetPermission().AccessMode = perm.AccessModeRead
|
||||||
|
} else {
|
||||||
|
ctx.GetPermission().AccessMode = perm.AccessModeWrite
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctx.GetRepository().LoadUnits(ctx.GetContext()); err != nil {
|
||||||
|
ctx.Error(http.StatusInternalServerError, "LoadUnits", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.GetPermission().Units = ctx.GetRepository().Units
|
||||||
|
ctx.GetPermission().UnitsMode = make(map[unit.Type]perm.AccessMode)
|
||||||
|
for _, u := range ctx.GetRepository().Units {
|
||||||
|
ctx.GetPermission().UnitsMode[u.Type] = ctx.GetPermission().AccessMode
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
permission, err := access_model.GetUserRepoPermissionWithReducer(ctx.GetContext(), ctx.GetRepository(), ctx.GetDoer(), ctx.GetReducer())
|
||||||
|
if err != nil {
|
||||||
|
ctx.Error(http.StatusInternalServerError, "GetUserRepoPermissionWithReducer", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.SetPermission(&permission)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ctx.GetPermission().HasAccess() {
|
||||||
|
ctx.NotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
24
routers/api/v1/permissions/req_admin.go
Normal file
24
routers/api/v1/permissions/req_admin.go
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package permissions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
"forgejo.org/models/unit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ReqAdmin(ctx Context, unitTypes []unit.Type) {
|
||||||
|
if len(unitTypes) > 0 && !slices.ContainsFunc(unitTypes, func(unitType unit.Type) bool {
|
||||||
|
return ctx.GetRepository().UnitEnabled(ctx.GetContext(), unitType)
|
||||||
|
}) {
|
||||||
|
ctx.NotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !IsUserRepoAdmin(ctx) && !IsUserSiteAdmin(ctx) {
|
||||||
|
ctx.Error(http.StatusForbidden, "reqAdmin", "user should be an owner or a collaborator with admin write of a repository")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
15
routers/api/v1/permissions/req_any_repo_reader.go
Normal file
15
routers/api/v1/permissions/req_any_repo_reader.go
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package permissions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ReqAnyRepoReader(ctx Context) {
|
||||||
|
if !ctx.GetPermission().HasAccess() && !IsUserSiteAdmin(ctx) {
|
||||||
|
ctx.Error(http.StatusForbidden, "reqAnyRepoReader", "user should have any permission to read repository or permissions of site admin")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
23
routers/api/v1/permissions/req_basic_or_rev_proxy_auth.go
Normal file
23
routers/api/v1/permissions/req_basic_or_rev_proxy_auth.go
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package permissions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"forgejo.org/modules/setting"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ReqBasicOrRevProxyAuth(ctx Context) {
|
||||||
|
if ctx.GetIsSigned() && setting.Service.EnableReverseProxyAuthAPI && ctx.GetAuthentication().IsReverseProxyAuthentication() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Require basic authorization method to be used and that basic
|
||||||
|
// authorization used password login to verify the user.
|
||||||
|
if !ctx.GetAuthentication().IsPasswordAuthentication() {
|
||||||
|
ctx.Error(http.StatusUnauthorized, "reqBasicAuth", "auth method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
16
routers/api/v1/permissions/req_explore_sign_in.go
Normal file
16
routers/api/v1/permissions/req_explore_sign_in.go
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package permissions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"forgejo.org/modules/setting"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ReqExploreSignIn(ctx Context) {
|
||||||
|
if (setting.Service.RequireSignInView || setting.Service.Explore.RequireSigninView) && !ctx.GetIsSigned() {
|
||||||
|
ctx.Error(http.StatusUnauthorized, "reqExploreSignIn", "you must be signed in to search for users")
|
||||||
|
}
|
||||||
|
}
|
||||||
15
routers/api/v1/permissions/req_git_hook.go
Normal file
15
routers/api/v1/permissions/req_git_hook.go
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package permissions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ReqGitHook(ctx Context) {
|
||||||
|
if !ctx.GetDoer().CanEditGitHook() {
|
||||||
|
ctx.Error(http.StatusForbidden, "", "must be allowed to edit Git hooks")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
38
routers/api/v1/permissions/req_org_membership.go
Normal file
38
routers/api/v1/permissions/req_org_membership.go
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package permissions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"forgejo.org/models/organization"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ReqOrgMembership(ctx Context) {
|
||||||
|
if IsUserSiteAdmin(ctx) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var orgID int64
|
||||||
|
if ctx.GetOrg() != nil {
|
||||||
|
orgID = ctx.GetOrg().ID
|
||||||
|
} else if ctx.GetTeam() != nil {
|
||||||
|
orgID = ctx.GetTeam().OrgID
|
||||||
|
} else {
|
||||||
|
ctx.Error(http.StatusInternalServerError, "", "reqOrgMembership: unprepared context")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if isMember, err := organization.IsOrganizationMember(ctx.GetContext(), orgID, ctx.GetDoer().ID); err != nil {
|
||||||
|
ctx.Error(http.StatusInternalServerError, "IsOrganizationMember", err)
|
||||||
|
return
|
||||||
|
} else if !isMember {
|
||||||
|
if ctx.GetOrg() != nil {
|
||||||
|
ctx.Error(http.StatusForbidden, "", "Must be an organization member")
|
||||||
|
} else {
|
||||||
|
ctx.NotFound()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
39
routers/api/v1/permissions/req_org_ownership.go
Normal file
39
routers/api/v1/permissions/req_org_ownership.go
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package permissions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"forgejo.org/models/organization"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ReqOrgOwnership(ctx Context) {
|
||||||
|
if IsUserSiteAdmin(ctx) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var orgID int64
|
||||||
|
if ctx.GetOrg() != nil {
|
||||||
|
orgID = ctx.GetOrg().ID
|
||||||
|
} else if ctx.GetTeam() != nil {
|
||||||
|
orgID = ctx.GetTeam().OrgID
|
||||||
|
} else {
|
||||||
|
ctx.Error(http.StatusInternalServerError, "", "reqOrgOwnership: unprepared context")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isOwner, err := organization.IsOrganizationOwner(ctx.GetContext(), orgID, ctx.GetDoer().ID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Error(http.StatusInternalServerError, "IsOrganizationOwner", err)
|
||||||
|
return
|
||||||
|
} else if !isOwner {
|
||||||
|
if ctx.GetOrg() != nil {
|
||||||
|
ctx.Error(http.StatusForbidden, "", "Must be an organization owner")
|
||||||
|
} else {
|
||||||
|
ctx.NotFound()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
24
routers/api/v1/permissions/req_owner.go
Normal file
24
routers/api/v1/permissions/req_owner.go
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package permissions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
"forgejo.org/models/unit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ReqOwner(ctx Context, unitTypes []unit.Type) {
|
||||||
|
if len(unitTypes) > 0 && !slices.ContainsFunc(unitTypes, func(unitType unit.Type) bool {
|
||||||
|
return ctx.GetRepository().UnitEnabled(ctx.GetContext(), unitType)
|
||||||
|
}) {
|
||||||
|
ctx.NotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ctx.GetPermission().IsOwner() && !IsUserSiteAdmin(ctx) {
|
||||||
|
ctx.Error(http.StatusForbidden, "reqOwner", "user should be the owner of the repo")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
17
routers/api/v1/permissions/req_package_access.go
Normal file
17
routers/api/v1/permissions/req_package_access.go
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package permissions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"forgejo.org/models/perm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ReqPackageAccess(ctx Context, accessMode perm.AccessMode) {
|
||||||
|
if ctx.GetPackageAccessMode() < accessMode && !IsUserSiteAdmin(ctx) {
|
||||||
|
ctx.Error(http.StatusForbidden, "reqPackageAccess", "user should have specific permission or be a site admin")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
16
routers/api/v1/permissions/req_repo_branch_writer.go
Normal file
16
routers/api/v1/permissions/req_repo_branch_writer.go
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package permissions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
issues_model "forgejo.org/models/issues"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ReqRepoBranchWriter(ctx Context, branch string) {
|
||||||
|
if !issues_model.CanMaintainerWriteToBranch(ctx.GetContext(), *ctx.GetPermission(), branch, ctx.GetDoer()) && !IsUserSiteAdmin(ctx) {
|
||||||
|
ctx.Error(http.StatusForbidden, "reqRepoBranchWriter", "user should have a permission to write to this branch")
|
||||||
|
}
|
||||||
|
}
|
||||||
21
routers/api/v1/permissions/req_repo_reader.go
Normal file
21
routers/api/v1/permissions/req_repo_reader.go
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package permissions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"forgejo.org/models/unit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ReqRepoReader(ctx Context, unitType unit.Type) {
|
||||||
|
if !ctx.GetRepository().UnitEnabled(ctx.GetContext(), unitType) {
|
||||||
|
ctx.NotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !ctx.GetPermission().CanRead(unitType) && !IsUserRepoAdmin(ctx) && !IsUserSiteAdmin(ctx) {
|
||||||
|
ctx.Error(http.StatusForbidden, "reqRepoReader", "user should have specific read permission or be a repo admin or a site admin")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
24
routers/api/v1/permissions/req_repo_writer.go
Normal file
24
routers/api/v1/permissions/req_repo_writer.go
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package permissions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
"forgejo.org/models/unit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ReqRepoWriter(ctx Context, unitTypes []unit.Type) {
|
||||||
|
if !slices.ContainsFunc(unitTypes, func(unitType unit.Type) bool {
|
||||||
|
return ctx.GetRepository().UnitEnabled(ctx.GetContext(), unitType)
|
||||||
|
}) {
|
||||||
|
ctx.NotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !IsUserRepoWriter(ctx, unitTypes) && !IsUserRepoAdmin(ctx) && !IsUserSiteAdmin(ctx) {
|
||||||
|
ctx.Error(http.StatusForbidden, "reqRepoWriter", "user should have a permission to write to a repo")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
24
routers/api/v1/permissions/req_self_or_admin.go
Normal file
24
routers/api/v1/permissions/req_self_or_admin.go
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package permissions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
user_model "forgejo.org/models/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ReqSelfOrAdmin(ctx Context) {
|
||||||
|
getID := func(user *user_model.User) int64 {
|
||||||
|
if user == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return user.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
if !IsUserSiteAdmin(ctx) && getID(ctx.GetUser()) != getID(ctx.GetDoer()) {
|
||||||
|
ctx.Error(http.StatusForbidden, "reqSelfOrAdmin", "doer should be the site admin or be same as the contextUser")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
15
routers/api/v1/permissions/req_site_admin.go
Normal file
15
routers/api/v1/permissions/req_site_admin.go
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package permissions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ReqSiteAdmin(ctx Context) {
|
||||||
|
if !IsUserSiteAdmin(ctx) {
|
||||||
|
ctx.Error(http.StatusForbidden, "reqSiteAdmin", "user should be the site admin")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
44
routers/api/v1/permissions/req_team_membership.go
Normal file
44
routers/api/v1/permissions/req_team_membership.go
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package permissions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"forgejo.org/models/organization"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ReqTeamMembership(ctx Context) {
|
||||||
|
if IsUserSiteAdmin(ctx) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ctx.GetTeam() == nil {
|
||||||
|
ctx.Error(http.StatusInternalServerError, "", "reqTeamMembership: unprepared context")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
orgID := ctx.GetTeam().OrgID
|
||||||
|
isOwner, err := organization.IsOrganizationOwner(ctx.GetContext(), orgID, ctx.GetDoer().ID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Error(http.StatusInternalServerError, "IsOrganizationOwner", err)
|
||||||
|
return
|
||||||
|
} else if isOwner {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if isTeamMember, err := organization.IsTeamMember(ctx.GetContext(), orgID, ctx.GetTeam().ID, ctx.GetDoer().ID); err != nil {
|
||||||
|
ctx.Error(http.StatusInternalServerError, "IsTeamMember", err)
|
||||||
|
return
|
||||||
|
} else if !isTeamMember {
|
||||||
|
isOrgMember, err := organization.IsOrganizationMember(ctx.GetContext(), orgID, ctx.GetDoer().ID)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Error(http.StatusInternalServerError, "IsOrganizationMember", err)
|
||||||
|
} else if isOrgMember {
|
||||||
|
ctx.Error(http.StatusForbidden, "", "Must be a team member")
|
||||||
|
} else {
|
||||||
|
ctx.NotFound()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
20
routers/api/v1/permissions/req_token.go
Normal file
20
routers/api/v1/permissions/req_token.go
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package permissions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ReqToken(ctx Context) {
|
||||||
|
// If actions token is present
|
||||||
|
if ctx.GetAuthentication().ActionsTaskID().Has() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctx.GetIsSigned() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Error(http.StatusUnauthorized, "reqToken", "token is required")
|
||||||
|
}
|
||||||
14
routers/api/v1/permissions/req_users_explore_enabled.go
Normal file
14
routers/api/v1/permissions/req_users_explore_enabled.go
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package permissions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"forgejo.org/modules/setting"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ReqUsersExploreEnabled(ctx Context) {
|
||||||
|
if setting.Service.Explore.DisableUsersPage {
|
||||||
|
ctx.NotFound()
|
||||||
|
}
|
||||||
|
}
|
||||||
20
routers/api/v1/permissions/req_valid_comment_id.go
Normal file
20
routers/api/v1/permissions/req_valid_comment_id.go
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package permissions
|
||||||
|
|
||||||
|
import (
|
||||||
|
issues_model "forgejo.org/models/issues"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ReqValidCommentID(ctx Context, comment *issues_model.Comment) {
|
||||||
|
if comment.Issue == nil || comment.Issue.RepoID != ctx.GetRepository().ID {
|
||||||
|
ctx.NotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !ctx.GetPermission().CanReadIssuesOrPulls(comment.Issue.IsPull) {
|
||||||
|
ctx.NotFound()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
17
routers/api/v1/permissions/req_webhooks_enabled.go
Normal file
17
routers/api/v1/permissions/req_webhooks_enabled.go
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package permissions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"forgejo.org/modules/setting"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ReqWebhooksEnabled(ctx Context) {
|
||||||
|
if setting.DisableWebhooks {
|
||||||
|
ctx.Error(http.StatusForbidden, "", "webhooks disabled by administrator")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package permissions
|
||||||
|
|
||||||
|
import (
|
||||||
|
auth_model "forgejo.org/models/auth"
|
||||||
|
user_model "forgejo.org/models/user"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TokenRequiresRepoOwnerScope(ctx Context, owner *user_model.User, requiredScopeLevel auth_model.AccessTokenScopeLevel) {
|
||||||
|
var category auth_model.AccessTokenScopeCategory
|
||||||
|
if owner.IsOrganization() {
|
||||||
|
category = auth_model.AccessTokenScopeCategoryOrganization
|
||||||
|
} else {
|
||||||
|
category = auth_model.AccessTokenScopeCategoryUser
|
||||||
|
}
|
||||||
|
TokenRequiresScopes(ctx, []auth_model.AccessTokenScopeCategory{category}, requiredScopeLevel)
|
||||||
|
}
|
||||||
39
routers/api/v1/permissions/token_requires_scopes.go
Normal file
39
routers/api/v1/permissions/token_requires_scopes.go
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
package permissions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
auth_model "forgejo.org/models/auth"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TokenRequiresScopes(ctx Context, requiredScopeCategories []auth_model.AccessTokenScopeCategory, requiredScopeLevel auth_model.AccessTokenScopeLevel) {
|
||||||
|
// no scope required
|
||||||
|
if len(requiredScopeCategories) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Need OAuth2 token to be present.
|
||||||
|
hasScope, scope := ctx.GetAuthentication().Scope().Get()
|
||||||
|
if !hasScope {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// get the required scope for the given access level and category
|
||||||
|
requiredScopes := auth_model.GetRequiredScopes(requiredScopeLevel, requiredScopeCategories...)
|
||||||
|
allow, err := scope.HasScope(requiredScopes...)
|
||||||
|
if err != nil {
|
||||||
|
ctx.Error(http.StatusForbidden, "tokenRequiresScope", "checking scope failed: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !allow {
|
||||||
|
ctx.Error(http.StatusForbidden, "tokenRequiresScope", fmt.Sprintf("token does not have at least one of required scope(s): %v", requiredScopes))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.SetRequiredScopeCategories(requiredScopeCategories)
|
||||||
|
}
|
||||||
|
|
@ -10,11 +10,15 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"slices"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
auth_model "forgejo.org/models/auth"
|
||||||
issues_model "forgejo.org/models/issues"
|
issues_model "forgejo.org/models/issues"
|
||||||
|
org_model "forgejo.org/models/organization"
|
||||||
|
"forgejo.org/models/perm"
|
||||||
|
access_model "forgejo.org/models/perm/access"
|
||||||
quota_model "forgejo.org/models/quota"
|
quota_model "forgejo.org/models/quota"
|
||||||
|
repo_model "forgejo.org/models/repo"
|
||||||
"forgejo.org/models/unit"
|
"forgejo.org/models/unit"
|
||||||
user_model "forgejo.org/models/user"
|
user_model "forgejo.org/models/user"
|
||||||
mc "forgejo.org/modules/cache"
|
mc "forgejo.org/modules/cache"
|
||||||
|
|
@ -25,6 +29,7 @@ import (
|
||||||
"forgejo.org/modules/setting"
|
"forgejo.org/modules/setting"
|
||||||
"forgejo.org/modules/web"
|
"forgejo.org/modules/web"
|
||||||
web_types "forgejo.org/modules/web/types"
|
web_types "forgejo.org/modules/web/types"
|
||||||
|
apiv1_permissions "forgejo.org/routers/api/v1/permissions"
|
||||||
"forgejo.org/services/auth"
|
"forgejo.org/services/auth"
|
||||||
"forgejo.org/services/authz"
|
"forgejo.org/services/authz"
|
||||||
|
|
||||||
|
|
@ -178,6 +183,88 @@ func (ctx *APIContext) ServerError(title string, err error) {
|
||||||
ctx.Error(http.StatusInternalServerError, title, err)
|
ctx.Error(http.StatusInternalServerError, title, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (ctx *APIContext) GetContext() context.Context {
|
||||||
|
return ctx.originCtx
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctx *APIContext) GetRepository() *repo_model.Repository {
|
||||||
|
return ctx.Repo.Repository
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctx *APIContext) GetDoer() *user_model.User {
|
||||||
|
return ctx.Doer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctx *APIContext) GetUser() *user_model.User {
|
||||||
|
return ctx.ContextUser
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctx *APIContext) GetOrg() *org_model.Organization {
|
||||||
|
return ctx.Org.Organization
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctx *APIContext) GetTeam() *org_model.Team {
|
||||||
|
return ctx.Org.Team
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctx *APIContext) GetPackageOwner() *user_model.User {
|
||||||
|
if ctx.Package == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return ctx.Package.Owner
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctx *APIContext) GetPackageAccessMode() perm.AccessMode {
|
||||||
|
if ctx.Package == nil {
|
||||||
|
return perm.AccessModeNone
|
||||||
|
}
|
||||||
|
return ctx.Package.AccessMode
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctx *APIContext) GetPermission() *access_model.Permission {
|
||||||
|
return &ctx.Repo.Permission
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctx *APIContext) SetPermission(permission *access_model.Permission) {
|
||||||
|
ctx.Repo.Permission = *permission
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctx *APIContext) GetIsSigned() bool {
|
||||||
|
return ctx.IsSigned
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctx *APIContext) GetPublicOnly() bool {
|
||||||
|
return ctx.PublicOnly
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctx *APIContext) SetPublicOnly(publicOnly bool) {
|
||||||
|
ctx.PublicOnly = publicOnly
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctx *APIContext) GetReducer() authz.AuthorizationReducer {
|
||||||
|
return ctx.Reducer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctx *APIContext) SetReducer(reducer authz.AuthorizationReducer) {
|
||||||
|
ctx.Reducer = reducer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctx *APIContext) GetAuthentication() auth.AuthenticationResult {
|
||||||
|
return ctx.Authentication
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctx *APIContext) GetRequiredScopeCategories() []auth_model.AccessTokenScopeCategory {
|
||||||
|
requiredScopeCategories, ok := ctx.Data["requiredScopeCategories"].([]auth_model.AccessTokenScopeCategory)
|
||||||
|
if !ok || len(requiredScopeCategories) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return requiredScopeCategories
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ctx *APIContext) SetRequiredScopeCategories(requiredScopeCategories []auth_model.AccessTokenScopeCategory) {
|
||||||
|
ctx.Data["requiredScopeCategories"] = requiredScopeCategories
|
||||||
|
}
|
||||||
|
|
||||||
// Error responds with an error message to client with given obj as the message.
|
// Error responds with an error message to client with given obj as the message.
|
||||||
// If status is 500, also it prints error to log.
|
// If status is 500, also it prints error to log.
|
||||||
func (ctx *APIContext) Error(status int, title string, obj any) {
|
func (ctx *APIContext) Error(status int, title string, obj any) {
|
||||||
|
|
@ -218,6 +305,10 @@ func (ctx *APIContext) InternalServerError(err error) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (ctx *APIContext) GetError() error {
|
||||||
|
return errors.New("unexpected call to APIContext.GetError")
|
||||||
|
}
|
||||||
|
|
||||||
type apiContextKeyType struct{}
|
type apiContextKeyType struct{}
|
||||||
|
|
||||||
var apiContextKey = apiContextKeyType{}
|
var apiContextKey = apiContextKeyType{}
|
||||||
|
|
@ -452,23 +543,17 @@ func (ctx *APIContext) NotFoundOrServerError(logMsg string, errCheck func(error)
|
||||||
|
|
||||||
// IsUserSiteAdmin returns true if current user is a site admin
|
// IsUserSiteAdmin returns true if current user is a site admin
|
||||||
func (ctx *APIContext) IsUserSiteAdmin() bool {
|
func (ctx *APIContext) IsUserSiteAdmin() bool {
|
||||||
if !ctx.Reducer.AllowAdminOverride() {
|
return apiv1_permissions.IsUserSiteAdmin(ctx)
|
||||||
return false
|
|
||||||
}
|
|
||||||
return ctx.IsSigned && ctx.Doer.IsAdmin
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsUserRepoAdmin returns true if current user is admin in current repo
|
// IsUserRepoAdmin returns true if current user is admin in current repo
|
||||||
func (ctx *APIContext) IsUserRepoAdmin() bool {
|
func (ctx *APIContext) IsUserRepoAdmin() bool {
|
||||||
if !ctx.Reducer.AllowAdminOverride() {
|
return apiv1_permissions.IsUserRepoAdmin(ctx)
|
||||||
return false
|
|
||||||
}
|
|
||||||
return ctx.Repo.IsAdmin()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsUserRepoWriter returns true if current user has write privilege in current repo
|
// IsUserRepoWriter returns true if current user has write privilege in current repo
|
||||||
func (ctx *APIContext) IsUserRepoWriter(unitTypes []unit.Type) bool {
|
func (ctx *APIContext) IsUserRepoWriter(unitTypes []unit.Type) bool {
|
||||||
return slices.ContainsFunc(unitTypes, ctx.Repo.CanWrite)
|
return apiv1_permissions.IsUserRepoWriter(ctx, unitTypes)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns true when the requests indicates that it accepts a Github response.
|
// Returns true when the requests indicates that it accepts a Github response.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue