There's not a perfect way to do it. You can use hooks (described below) to push devs in the right direction; if you expect that devs will go along with the no-ff policy but just want to help them avoid pushing mistakes, that should be fine. But if you need truly strict enforcement then you may just have to use access controls that limit who can push to the branch.
In general to enforce a policy about how a branch moves you would use hooks. Information about using hooks with bitbucket can be found here: https://confluence.atlassian.com/bitbucketserver/using-repository-hooks-776639836.html
So the way that would work is, the developer might locally be able to do a fast-forward; but you would write a hook that recognized and rejected the fast-forward to that branch when they try to push it to the remote. You let your developers know you're doing this, and then any sensible developer will apply the --no-ff
configuration locally so that they won't accidentally make local commits that they have to waste time undoing. You can even put a script in the repo that updates the local configuration to make that easy. (But you can't enforce that it must be run locally - which is why you depend on the hook.)
However, at the time of a push, it's not generally possible to tell if a fast-forward merge occurred[1]. The best you can do is reject pushes where it seems likely that a fast-forward occurred, and accept those where it seems likely that no fast-forward occurred.
So there are two things you could look for. Probably the most "bang for your buck" is to require that all commits on the branch (traversing first parents only) be a merge commit. So your hook could run something like git rev-list --first-parent --no-merges
on the range of commits from the old ref to the proposed updated ref. If this produced any output, then the push is rejected.
In addition to rejecting fast-forward merges, this would also reject commits made directly to the branch. That may be what you want, though if an exception case arises it may be a hassle to deal with. Regardless, commits made directly to the branch are generally indistinguishable from what you typically get from fast-forward merges.
Technically it would still be possible for someone to do something like this
x -- x -- o <--(target_branch)
|\
| ----- M <-(other_branch)
\ /
A -- B
and then fast-forward target_branch
onto other_branch
without triggering the hook.
Another thing you could do is to have the hook reject the push if the first-parent commits of the target branch would include any commit that was ever pushed to a different branch in the remote. On its own this wouldn't prevent someone creating a local branch and fast-forwarding onto it without ever having pushed it. It could be used as an extra check along with the first "merges only" check. But it would only catch the occasional edge case, and it would be more complicated to write and more expensive to run - so it's less likely to be worthwhile.
[1] Technically from the remote's perspective the way a branch moves on push is normally expected to be a fast-forward. It may be a fast-forward onto a merge that was created in the local repo, but to the remote itself that still "looks like" a fast forward, except in the case of force pushing.