Skip to content

Commit

Permalink
Fixed permissions for tag urls
Browse files Browse the repository at this point in the history
  • Loading branch information
cjmalloy committed Sep 26, 2024
1 parent f669b37 commit 1e975a1
Show file tree
Hide file tree
Showing 6 changed files with 246 additions and 105 deletions.
26 changes: 24 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
Knowledge Management Server

[![Build & Test](https://github.com/cjmalloy/jasper/actions/workflows/docker-image.yml/badge.svg)](https://github.com/cjmalloy/jasper/actions/workflows/docker-image.yml)
[![SwaggerHub](https://img.shields.io/badge/SwaggerHub-1.2.29-brightgreen)](https://app.swaggerhub.com/apis/cjmalloy/Jasper)
[![SwaggerHub](https://img.shields.io/badge/SwaggerHub-1.2.30-brightgreen)](https://app.swaggerhub.com/apis/cjmalloy/Jasper)
[![Artifact Hub](https://img.shields.io/endpoint?url=https://artifacthub.io/badge/repository/jasper)](https://artifacthub.io/packages/helm/jasper/jasper)

## Quickstart
Expand Down Expand Up @@ -529,6 +529,29 @@ The tag permissions are stored in the User entities:
* Can write ref with tag
* Can edit tag Ext

### Special URL Schemas

### Cache
URLs that have the `cache:` scheme represent items stored in a file cache.
Most URLs with a resource in a file cache have a standard `https:` scheme,
as they are just a cache of a resource that exists elsewhere.
When a file is pushed into the cache (such as a generated thumbnail), it is
generated a random `cache:<uuid>` URL.

#### Tag URLs
URLs that point to a tag, such as `tag:/history` ignore regular tagging access rules.
Instead, you can access this Ref if you can access the tag it points to.

#### User URLs
URLs that point to a user tag, such as `tag:/+user/chris` are always owned by the user.
These specials URLs can also be used to store per-plugin config data,
such as `tag:/+user/chris?url=tag:/plugin/kanban`.
Visibility of plugin setting can be set on a per-user, per-plugin basis.
For convenience, the user URL is used if a blank URL is passed to the tagging response controller.
This allows you to quickly ensure settings are initialized and fetch / edit Ref plugins and tags to read settings.
If a tag are passed, for example `plugin/kanban`, the default is the kanban user settings Ref: `tag:/+user/chris?url=tag:/plugin/kanban`.
If a blank URL and a blank tag are passed, the default is the generic user settings Ref: `tag:/+user/chris`.

### Special Tags
Some public tags have special significance:
* `public`: everyone can read
Expand Down Expand Up @@ -575,7 +598,6 @@ Jasper generates the following metadata in Refs:
* Obsolete: flag set if another origin contains the newest version of this Ref

## Server Scripting

When the `scripts` profile is active, scripts may be attached to Refs with either the `plugin/delta` tag or the
`plugin/script` tag.
Only admin users may install scripts and they run with very few guardrails. A regular user may invoke the script
Expand Down
23 changes: 23 additions & 0 deletions src/main/java/jasper/domain/proj/Tag.java
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,33 @@ default String getQualifiedTag() {
return getTag() + getOrigin();
}

static boolean userUrl(String url) {
return
userUrl(url, "+user") || url.startsWith("tag:/+user/") ||
userUrl(url, "_user") || url.startsWith("tag:/_user/");
}

static boolean userUrl(String url, String user) {
return url.equals("tag:/" + user) ||
url.startsWith("tag:/" + user + "?") ||
url.startsWith("tag:/" + user + "/");
}

static String urlForUser(String url, String user) {
if (isBlank(url)) return "tag:/" + user;
return "tag:/" + user + "?url=" + url;
}

static boolean tagUrl(String url) {
return url.startsWith("tag:/");
}

static String urlToTag(String url) {
var tag = url.substring("tag:/".length());
if (tag.contains("?")) return tag.substring(0, tag.indexOf("?"));
return tag;
}

static boolean isPublicTag(String tag) {
if (isBlank(tag)) return false;
return !tag.startsWith("_") && !tag.startsWith("+");
Expand Down
12 changes: 11 additions & 1 deletion src/main/java/jasper/security/Auth.java
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@
import static io.jsonwebtoken.Jwts.claims;
import static jasper.config.JacksonConfiguration.dump;
import static jasper.domain.proj.HasOrigin.isSubOrigin;
import static jasper.domain.proj.Tag.tagUrl;
import static jasper.domain.proj.Tag.urlToTag;
import static jasper.domain.proj.Tag.userUrl;
import static jasper.repository.spec.OriginSpec.isOrigin;
import static jasper.repository.spec.QualifiedTag.qt;
import static jasper.repository.spec.QualifiedTag.qtList;
Expand Down Expand Up @@ -264,6 +267,10 @@ public boolean canReadRef(HasTags ref) {
if (hasRole(MOD)) return true;
// Min Role
if (!minRole()) return false;
// User URL
if (userUrl(ref.getUrl())) return isLoggedIn() && userUrl(ref.getUrl(), getUserTag().tag);
// Tag URLs
if (tagUrl(ref.getUrl())) return canReadTag(urlToTag(ref.getUrl()) + ref.getOrigin());
// No tags, only mods can read
if (ref.getTags() == null) return false;
// Add the ref's origin to its tag list
Expand Down Expand Up @@ -312,9 +319,12 @@ public boolean canWriteRef(String url, String origin) {
if (!minRole()) return false;
// Minimum role for writing
if (!minWriteRole()) return false;
// User URL
if (userUrl(url)) return isLoggedIn() && userUrl(url, getUserTag().tag);
// Tag URLs
if (tagUrl(url)) return canWriteTag(urlToTag(url) + origin);
var maybeExisting = refRepository.findOneByUrlAndOrigin(url, origin);
if (maybeExisting.isEmpty()) {
if (url.startsWith("tag:/")) return hasRole(MOD) || url.startsWith("tag:/" + getUserTag().tag + "?");
// If we're creating, simply having the role USER is enough
return hasRole(USER);
}
Expand Down
40 changes: 34 additions & 6 deletions src/main/java/jasper/service/TaggingService.java
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
import jasper.errors.NotFoundException;
import jasper.repository.RefRepository;
import jasper.security.Auth;
import jasper.service.dto.DtoMapper;
import jasper.service.dto.RefDto;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
Expand All @@ -19,6 +21,8 @@
import java.util.List;

import static jasper.domain.proj.Tag.urlForUser;
import static org.apache.commons.lang3.StringUtils.isBlank;
import static org.apache.commons.lang3.StringUtils.isNotBlank;

@Service
public class TaggingService {
Expand All @@ -33,6 +37,9 @@ public class TaggingService {
@Autowired
Auth auth;

@Autowired
DtoMapper mapper;

@PreAuthorize("@auth.canTag(#tag, #url, #origin)")
@Timed(value = "jasper.service", extraTags = {"service", "tag"}, histogram = true)
public Instant create(String tag, String url, String origin) {
Expand Down Expand Up @@ -84,11 +91,17 @@ public Instant tag(List<String> tags, String url, String origin) {
return ref.getModified();
}

@PreAuthorize("@auth.hasRole('USER') and @auth.canAddTag(#tag)")
@Timed(value = "jasper.service", extraTags = {"service", "tag"}, histogram = true)
public RefDto getResponse(String tag, String url) {
return mapper.domainToDto(getRef(url, tag));
}

@PreAuthorize("@auth.hasRole('USER') and @auth.canAddTag(#tag)")
@Timed(value = "jasper.service", extraTags = {"service", "tag"}, histogram = true)
public void createResponse(String tag, String url) {
var ref = getResponseRef(url);
if (!ref.getTags().contains(tag)) {
var ref = getRef(url, tag);
if (isNotBlank(tag) && !ref.getTags().contains(tag)) {
ref.getTags().add(tag);
}
ingest.update(ref, false);
Expand All @@ -97,23 +110,38 @@ public void createResponse(String tag, String url) {
@PreAuthorize("@auth.hasRole('USER') and @auth.canAddTag(#tag)")
@Timed(value = "jasper.service", extraTags = {"service", "tag"}, histogram = true)
public void deleteResponse(String tag, String url) {
var ref = getResponseRef(url);
var ref = getRef(url, tag);
ref.removeTag(tag);
ingest.update(ref, true);
}

@PreAuthorize("@auth.hasRole('USER') and @auth.canAddTags(@auth.tagPatch(#tags))")
@Timed(value = "jasper.service", extraTags = {"service", "tag"}, histogram = true)
public void respond(List<String> tags, String url) {
var ref = getResponseRef(url);
var ref = getRef(url, null);
for (var tag : tags) ref.addTag(tag);
ingest.update(ref, true);
}

private Ref getRef(String url, String tag) {
if (isNotBlank(url)) return getResponseRef(url);
if (isNotBlank(tag)) return getTagRef(tag);
return getUserRef();
}

private Ref getUserRef() {
return getResponseRef(null);
}

private Ref getTagRef(String tag) {
return getResponseRef("tag:/" + tag);
}

private Ref getResponseRef(String url) {
if (auth.getUserTag() == null || isBlank(auth.getUserTag().tag)) throw new NotFoundException("User URL");
var userUrl = urlForUser(url, auth.getUserTag().tag);
return refRepository.findOneByUrlAndOrigin(userUrl, auth.getOrigin()).map(ref -> {
if (ref.getSources() == null || !ref.getSources().contains(url)) ref.setSources(new ArrayList<>(List.of(url)));
if (isNotBlank(url) && (ref.getSources() == null || !ref.getSources().contains(url))) ref.setSources(new ArrayList<>(List.of(url)));
if (ref.getTags() == null || ref.getTags().contains("plugin/deleted")) {
ref.setTags(new ArrayList<>(List.of("internal", auth.getUserTag().tag)));
}
Expand All @@ -123,7 +151,7 @@ private Ref getResponseRef(String url) {
var ref = new Ref();
ref.setUrl(userUrl);
ref.setOrigin(auth.getOrigin());
ref.setSources(new ArrayList<>(List.of(url)));
if (isNotBlank(url)) ref.setSources(new ArrayList<>(List.of(url)));
ref.setTags(new ArrayList<>(List.of("internal", auth.getUserTag().tag)));
ingest.create(ref, false);
return ref;
Expand Down
35 changes: 30 additions & 5 deletions src/main/java/jasper/web/rest/TaggingController.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,26 @@
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import jasper.component.HttpCache;
import jasper.domain.Ref;
import jasper.domain.proj.HasOrigin;
import jasper.domain.proj.Tag;
import jasper.service.TaggingService;
import jasper.service.dto.RefDto;
import org.hibernate.validator.constraints.Length;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpStatus;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.context.request.WebRequest;

import javax.validation.constraints.Pattern;
import java.time.Instant;
Expand All @@ -36,15 +41,18 @@
@ApiResponse(responseCode = "400", content = @Content(schema = @Schema(ref = "https://opensource.zalando.com/problem/schema.yaml#/Problem"))),
@ApiResponse(responseCode = "403", content = @Content(schema = @Schema(ref = "https://opensource.zalando.com/problem/schema.yaml#/Problem"))),
@ApiResponse(responseCode = "404", content = @Content(schema = @Schema(ref = "https://opensource.zalando.com/problem/schema.yaml#/Problem"))),
@ApiResponse(responseCode = "409", content = @Content(schema = @Schema(ref = "https://opensource.zalando.com/problem/schema.yaml#/Problem"))),
})
public class TaggingController {

@Autowired
TaggingService taggingService;

@Autowired
HttpCache httpCache;

@ApiResponses({
@ApiResponse(responseCode = "200"),
@ApiResponse(responseCode = "409", content = @Content(schema = @Schema(ref = "https://opensource.zalando.com/problem/schema.yaml#/Problem"))),
})
@PostMapping
Instant createTags(
Expand All @@ -57,6 +65,7 @@ Instant createTags(

@ApiResponses({
@ApiResponse(responseCode = "200"),
@ApiResponse(responseCode = "409", content = @Content(schema = @Schema(ref = "https://opensource.zalando.com/problem/schema.yaml#/Problem"))),
})
@DeleteMapping
Instant deleteTags(
Expand All @@ -69,6 +78,7 @@ Instant deleteTags(

@ApiResponses({
@ApiResponse(responseCode = "200"),
@ApiResponse(responseCode = "409", content = @Content(schema = @Schema(ref = "https://opensource.zalando.com/problem/schema.yaml#/Problem"))),
})
@PatchMapping
Instant patchTags(
Expand All @@ -79,38 +89,53 @@ Instant patchTags(
return taggingService.tag(tags, url, origin);
}

@ApiResponses({
@ApiResponse(responseCode = "200"),
})
@GetMapping("response")
HttpEntity<RefDto> getResponse(
WebRequest request,
@RequestParam(defaultValue = "") @Length(max = TAG_LEN) @Pattern(regexp = Tag.REGEX) String tag,
@RequestParam(defaultValue = "") @Length(max = URL_LEN) @Pattern(regexp = Ref.REGEX) String url
) {
return httpCache.ifNotModified(request, taggingService.getResponse(tag, url));
}

@ApiResponses({
@ApiResponse(responseCode = "201"),
@ApiResponse(responseCode = "409", content = @Content(schema = @Schema(ref = "https://opensource.zalando.com/problem/schema.yaml#/Problem"))),
})
@PostMapping("response")
@ResponseStatus(HttpStatus.CREATED)
void createResponse(
@RequestParam @Length(max = TAG_LEN) @Pattern(regexp = Tag.REGEX) String tag,
@RequestParam @Length(max = URL_LEN) @Pattern(regexp = Ref.REGEX) String url
@RequestParam(defaultValue = "") @Length(max = TAG_LEN) @Pattern(regexp = Tag.REGEX) String tag,
@RequestParam(defaultValue = "") @Length(max = URL_LEN) @Pattern(regexp = Ref.REGEX) String url
) {
taggingService.createResponse(tag, url);
}

@ApiResponses({
@ApiResponse(responseCode = "204"),
@ApiResponse(responseCode = "409", content = @Content(schema = @Schema(ref = "https://opensource.zalando.com/problem/schema.yaml#/Problem"))),
})
@DeleteMapping("response")
@ResponseStatus(HttpStatus.NO_CONTENT)
void deleteResponse(
@RequestParam @Length(max = TAG_LEN) @Pattern(regexp = Tag.REGEX) String tag,
@RequestParam @Length(max = URL_LEN) @Pattern(regexp = Ref.REGEX) String url
@RequestParam(defaultValue = "") @Length(max = URL_LEN) @Pattern(regexp = Ref.REGEX) String url
) {
taggingService.deleteResponse(tag, url);
}

@ApiResponses({
@ApiResponse(responseCode = "204"),
@ApiResponse(responseCode = "409", content = @Content(schema = @Schema(ref = "https://opensource.zalando.com/problem/schema.yaml#/Problem"))),
})
@PatchMapping("response")
@ResponseStatus(HttpStatus.NO_CONTENT)
void patchResponse(
@RequestParam List<@Length(max = TAG_LEN + 1) @Pattern(regexp = Tag.ADD_REMOVE_REGEX) String> tags,
@RequestParam @Length(max = URL_LEN) @Pattern(regexp = Ref.REGEX) String url
@RequestParam(defaultValue = "") @Length(max = URL_LEN) @Pattern(regexp = Ref.REGEX) String url
) {
taggingService.respond(tags, url);
}
Expand Down
Loading

0 comments on commit 1e975a1

Please sign in to comment.